Skip to content

Commit d5402d1

Browse files
committed
merge prerelease branch
1 parent e7828dc commit d5402d1

12 files changed

+138
-75
lines changed

ldcontext/builder_multi.go

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,8 @@ func (m *MultiBuilder) Build() Context {
5656
}
5757

5858
if len(m.contexts) == 1 {
59-
// Never return a multi-kind context with just one kind; instead return the individual one
60-
c := m.contexts[0]
61-
if c.Multiple() {
62-
return Context{defined: true, err: lderrors.ErrContextKindMultiWithinMulti{}}
63-
}
64-
return c
59+
// If only one context was added, the result is just the same as that one
60+
return m.contexts[0]
6561
}
6662

6763
m.contextsCopyOnWrite = true // see note on ___CopyOnWrite in Builder.Build()
@@ -72,7 +68,6 @@ func (m *MultiBuilder) Build() Context {
7268

7369
// Check for conditions that could make a multi-kind context invalid
7470
var individualErrors map[string]error
75-
nestedMulti := false
7671
duplicates := false
7772
for i, c := range m.contexts {
7873
err := c.Err()
@@ -82,8 +77,6 @@ func (m *MultiBuilder) Build() Context {
8277
individualErrors = make(map[string]error)
8378
}
8479
individualErrors[string(c.Kind())] = err
85-
case c.Multiple(): // multi-kind inside multi-kind is not allowed
86-
nestedMulti = true
8780
default:
8881
for j := 0; j < i; j++ {
8982
if c.Kind() == m.contexts[j].Kind() { // same kind was seen twice
@@ -95,8 +88,6 @@ func (m *MultiBuilder) Build() Context {
9588
}
9689
var err error
9790
switch {
98-
case nestedMulti:
99-
err = lderrors.ErrContextKindMultiWithinMulti{}
10091
case duplicates:
10192
err = lderrors.ErrContextKindMultiDuplicates{}
10293
case len(individualErrors) != 0:
@@ -168,11 +159,28 @@ func (m *MultiBuilder) TryBuild() (Context, error) {
168159
//
169160
// It is invalid to add more than one context with the same Kind. This error is detected
170161
// when you call Build() or TryBuild().
162+
//
163+
// If the nested context is multi-kind, this is exactly equivalent to adding each of the
164+
// individual kinds from it separately. For instance, in the following example, "multi1" and
165+
// "multi2" end up being exactly the same:
166+
//
167+
// c1 := ldcontext.NewWithKind("kind1", "key1")
168+
// c2 := ldcontext.NewWithKind("kind2", "key2")
169+
// c3 := ldcontext.NewWithKind("kind3", "key3")
170+
//
171+
// multi1 := ldcontext.NewMultiBuilder().Add(c1).Add(c2).Add(c3).Build()
172+
//
173+
// c1plus2 := ldcontext.NewMultiBuilder().Add(c1).Add(c2).Build()
174+
// multi2 := ldcontext.NewMultiBuilder().Add(c1plus2).Add(c3).Build()
171175
func (m *MultiBuilder) Add(context Context) *MultiBuilder {
172176
if m.contextsCopyOnWrite {
173177
m.contexts = append([]Context(nil), m.contexts...)
174178
m.contextsCopyOnWrite = true
175179
}
176-
m.contexts = append(m.contexts, context)
180+
if context.Multiple() {
181+
m.contexts = append(m.contexts, context.multiContexts...)
182+
} else {
183+
m.contexts = append(m.contexts, context)
184+
}
177185
return m
178186
}

ldcontext/builder_multi_test.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@ func TestMultiBuilder(t *testing.T) {
2929
assert.Equal(t, []Context{sub1, sub2}, c0.GetAllIndividualContexts(nil))
3030
// other accessors are tested in context_test.go
3131
})
32+
33+
t.Run("nested multi-kind contexts are flattened", func(t *testing.T) {
34+
sub1 := NewWithKind("kind1", "key1")
35+
sub2 := NewWithKind("kind2", "key2")
36+
sub3 := NewWithKind("kind3", "key3")
37+
sub4 := NewWithKind("kind3", "key3")
38+
c0 := NewMultiBuilder().Add(sub2).Add(sub3).Build()
39+
c1 := NewMultiBuilder().Add(sub1).Add(c0).Add(sub4).Build()
40+
41+
expected := NewMultiBuilder().Add(sub1).Add(sub2).Add(sub3).Add(sub4).Build()
42+
assert.Equal(t, expected.multiContexts, c1.multiContexts)
43+
})
3244
}
3345

3446
func TestMultiBuilderFullyQualifiedKey(t *testing.T) {
@@ -43,6 +55,14 @@ func TestMultiBuilderFullyQualifiedKey(t *testing.T) {
4355
Build()
4456
assert.Equal(t, "kind-a:key-2:kind-b:key-4:kind-c:key-1:kind-d:key-3", c.FullyQualifiedKey())
4557
})
58+
59+
t.Run("keys are escaped", func(t *testing.T) {
60+
c := NewMultiBuilder().
61+
Add(NewWithKind("kind-a", "key-1")).
62+
Add(NewWithKind("kind-b", "key:2")).
63+
Build()
64+
assert.Equal(t, "kind-a:key-1:kind-b:key%3A2", c.FullyQualifiedKey())
65+
})
4666
}
4767

4868
func TestMultiBuilderErrors(t *testing.T) {
@@ -61,16 +81,6 @@ func TestMultiBuilderErrors(t *testing.T) {
6181
verifyError(t, NewMultiBuilder(), lderrors.ErrContextKindMultiWithNoKinds{})
6282
})
6383

64-
t.Run("nested multi", func(t *testing.T) {
65-
sub1 := NewWithKind("org", "my-key")
66-
sub2 := NewMulti(New("user-key"), NewWithKind("org", "other"))
67-
b0 := NewMultiBuilder().Add(sub1).Add(sub2)
68-
verifyError(t, b0, lderrors.ErrContextKindMultiWithinMulti{})
69-
70-
b1 := NewMultiBuilder().Add(sub2)
71-
verifyError(t, b1, lderrors.ErrContextKindMultiWithinMulti{})
72-
})
73-
7484
t.Run("duplicate kind", func(t *testing.T) {
7585
sub1 := NewWithKind("org", "my-org-key")
7686
sub2 := NewWithKind("user", "my-user-key")

ldcontext/builder_simple.go

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package ldcontext
22

33
import (
44
"fmt"
5-
"net/url"
5+
"strings"
66

77
"github.com/launchdarkly/go-sdk-common/v3/ldattr"
88
"github.com/launchdarkly/go-sdk-common/v3/lderrors"
@@ -279,8 +279,9 @@ func (b *Builder) SetString(attributeName string, value string) *Builder {
279279
// SetValue sets the value of any attribute for the Context.
280280
//
281281
// This includes only attributes that are addressable in evaluations-- not metadata such as
282-
// Secondary() or Private(). If attributeName is "secondary" or "privateAttributes", it is
283-
// ignored and no attribute is set.
282+
// Secondary() or Private(). If attributeName is "secondary" or "privateAttributes", you will be
283+
// setting an attribute with that name which you can use in evaluations or to record data for
284+
// your own purposes, but it will be unrelated to Secondary() and Private().
284285
//
285286
// This method uses the ldvalue.Value type to represent a value of any JSON type: null, boolean,
286287
// number, string, array, or object. For all attribute names that do not have special meaning
@@ -297,6 +298,9 @@ func (b *Builder) SetString(attributeName string, value string) *Builder {
297298
//
298299
// - "anonymous": Must be a boolean. See Builder.Anonymous().
299300
//
301+
// The attribute name "_meta" is not allowed, because it has special meaning in the JSON
302+
// schema for contexts; any attempt to set an attribute with this name has no effect.
303+
//
300304
// Values that are JSON arrays or objects have special behavior when referenced in flag/segment
301305
// rules.
302306
//
@@ -341,7 +345,7 @@ func (b *Builder) TrySetValue(attributeName string, value ldvalue.Value) bool {
341345
return false
342346
}
343347
b.Anonymous(value.BoolValue())
344-
case jsonPropPrivate, jsonPropSecondary:
348+
case jsonPropMeta:
345349
return false
346350
default:
347351
if value.IsNull() {
@@ -360,8 +364,10 @@ func (b *Builder) TrySetValue(attributeName string, value ldvalue.Value) bool {
360364
// (https://docs.launchdarkly.com/home/flags/targeting-users#targeting-rules-based-on-user-attributes)
361365
// as follows: if you have chosen to bucket users by a specific attribute, the secondary key (if set)
362366
// is used to further distinguish between users who are otherwise identical according to that attribute.
363-
// This value is not addressable as an attribute in evaluations: that is, a rule clause cannot use the
364-
// attribute name "secondary".
367+
//
368+
// This is a metadata property, rather than an attribute that can be addressed in evaluations: that is,
369+
// a rule clause that references the attribute name "secondary" will not use this value, but instead will
370+
// use whatever value (if any) you have set for the name "secondary" with a method such as SetString.
365371
//
366372
// Setting this value to an empty string is not the same as leaving it unset. If you need to clear this
367373
// attribute to a "no value" state, use OptSecondary().
@@ -374,9 +380,6 @@ func (b *Builder) Secondary(value string) *Builder {
374380
// Calling b.OptSecondary(ldvalue.NewOptionalString("x")) is equivalent to b.Secondary("x"), but since it uses
375381
// the OptionalString type, it also allows clearing a previously set name with
376382
// b.OptSecondary(ldvalue.OptionalString{}).
377-
//
378-
// This value is not addressable as an attribute in evaluations: that is, a rule clause cannot use the
379-
// attribute name "secondary".
380383
func (b *Builder) OptSecondary(value ldvalue.OptionalString) *Builder {
381384
if b != nil {
382385
b.secondary = value
@@ -396,7 +399,7 @@ func (b *Builder) OptSecondary(value ldvalue.OptionalString) *Builder {
396399
// other attributes may be included (so, for instance, Anonymous does not mean there is no Name).
397400
//
398401
// This value is also addressable in evaluations as the attribute name "anonymous". It is always treated as
399-
// a boolean true or false in evaluations.
402+
// a boolean true or false in evaluations; it cannot be null/undefined.
400403
func (b *Builder) Anonymous(value bool) *Builder {
401404
if b != nil {
402405
b.anonymous = value
@@ -407,9 +410,6 @@ func (b *Builder) Anonymous(value bool) *Builder {
407410
// Private designates any number of Context attributes, or properties within them, as private: that is,
408411
// their values will not be sent to LaunchDarkly.
409412
//
410-
// (TKTK: possibly move some of this conceptual information to a non-platform-specific docs page and/or
411-
// have docs team copyedit it here)
412-
//
413413
// Each parameter can be a simple attribute name, such as "email". Or, if the first character is a slash,
414414
// the parameter is interpreted as a slash-delimited path to a property within a JSON object, where the
415415
// first path component is a Context attribute name and each following component is a nested property name:
@@ -434,6 +434,11 @@ func (b *Builder) Anonymous(value bool) *Builder {
434434
// SetString("lastName", "Menard").
435435
// Private("firstName").
436436
// Build()
437+
//
438+
// This is a metadata property, rather than an attribute that can be addressed in evaluations: that is,
439+
// a rule clause that references the attribute name "private" (or "privateAttributes", as it appears
440+
// in JSON representations) will not use this value, but instead will use whatever value (if any) you
441+
// have set for that name with a method such as SetString.
437442
func (b *Builder) Private(attrRefStrings ...string) *Builder {
438443
refs := make([]ldattr.Ref, 0, 20) // arbitrary capacity that's likely greater than needed, to preallocate on stack
439444
for _, s := range attrRefStrings {
@@ -520,10 +525,12 @@ func (b *Builder) copyFrom(fromContext Context) {
520525
func makeFullyQualifiedKeySingleKind(kind Kind, key string, omitDefaultKind bool) string {
521526
// Per the users-to-contexts specification, the fully-qualified key for a single-kind context is:
522527
// - equal to the regular "key" property, if the kind is "user" (a.k.a. DefaultKind)
523-
// - or, for any other kind, it's the kind plus ":" plus the result of URL-encoding the "key"
524-
// property (the URL-encoding is to avoid ambiguity if the key contains colons).
528+
// - or, for any other kind, it's the kind plus ":" plus the result of partially URL-encoding the
529+
// "key" property ("partially URL-encoding" here means that ':' and '%' are percent-escaped; other
530+
// URL-encoding behaviors are inconsistent across platforms, so we do not use a library function).
525531
if omitDefaultKind && kind == DefaultKind {
526532
return key
527533
}
528-
return fmt.Sprintf("%s:%s", kind, url.PathEscape(key))
534+
escapedKey := strings.ReplaceAll(strings.ReplaceAll(key, "%", "%25"), ":", "%3A")
535+
return fmt.Sprintf("%s:%s", kind, escapedKey)
529536
}

ldcontext/builder_simple_test.go

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ func TestBuilderFullyQualifiedKey(t *testing.T) {
8787
c := NewWithKind("org", "my-org-key")
8888
assert.Equal(t, "org:my-org-key", c.FullyQualifiedKey())
8989
})
90+
91+
t.Run("key is escaped", func(t *testing.T) {
92+
c := NewWithKind("org", "my:key%x/y")
93+
assert.Equal(t, "org:my%3Akey%25x/y", c.FullyQualifiedKey())
94+
})
9095
}
9196

9297
func TestBuilderBasicSetters(t *testing.T) {
@@ -297,16 +302,25 @@ func TestBuilderSetBuiltInAttributesByName(t *testing.T) {
297302
}
298303

299304
func TestBuilderSetValueCannotSetMetaProperties(t *testing.T) {
300-
t.Run("privateAttributes", func(t *testing.T) {
301-
assert.Equal(t,
302-
makeBasicBuilder(),
303-
makeBasicBuilder().SetValue("privateAttributes", ldvalue.ArrayOf(ldvalue.String("x"))))
304-
})
305+
for _, p := range []struct {
306+
name string
307+
value ldvalue.Value
308+
}{
309+
{"secondary", ldvalue.String("x")},
310+
{"privateAttributes", ldvalue.ArrayOf(ldvalue.String("x"))},
311+
} {
312+
t.Run(p.name, func(t *testing.T) {
313+
c := makeBasicBuilder().SetValue(p.name, p.value).Build()
314+
assert.Equal(t, p.value, c.attributes.Get(p.name))
315+
assert.Equal(t, ldvalue.OptionalString{}, c.secondary)
316+
assert.Len(t, c.privateAttrs, 0)
317+
})
318+
}
305319

306-
t.Run("secondary", func(t *testing.T) {
307-
assert.Equal(t,
308-
makeBasicBuilder(),
309-
makeBasicBuilder().SetValue("secondary", ldvalue.String("x")))
320+
t.Run("_meta", func(t *testing.T) {
321+
b := makeBasicBuilder()
322+
assert.False(t, b.TrySetValue("_meta", ldvalue.String("hi")))
323+
assert.Equal(t, 0, b.Build().attributes.Count())
310324
})
311325
}
312326

ldcontext/constructors.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,23 @@ func NewWithKind(kind Kind, key string) Context {
2828
// To create a single-kind Context, use New(), NewWithKind, or NewBuilder().
2929
//
3030
// For the returned Context to be valid, the contexts list must not be empty, and all of its
31-
// elements must be single-kind Contexts. Otherwise, the returned Context will be invalid as
32-
// reported by Context.Err().
31+
// elements must be valid Contexts. Otherwise, the returned Context will be invalid as reported
32+
// by Context.Err().
3333
//
34-
// If only one context parameter is given, NewMulti returns a single-kind context (that is,
35-
// just that same context) rather than a multi-kind context.
34+
// If only one context parameter is given, NewMulti returns that same context.
35+
//
36+
// If one of the nested contexts is multi-kind, this is exactly equivalent to adding each of the
37+
// individual kinds from it separately. For instance, in the following example, "multi1" and
38+
// "multi2" end up being exactly the same:
39+
//
40+
// c1 := ldcontext.NewWithKind("kind1", "key1")
41+
// c2 := ldcontext.NewWithKind("kind2", "key2")
42+
// c3 := ldcontext.NewWithKind("kind3", "key3")
43+
//
44+
// multi1 := ldcontext.NewMulti(c1, c2, c3)
45+
//
46+
// c1plus2 := ldcontext.NewMulti(c1, c2)
47+
// multi2 := ldcontext.NewMulti(c1plus2, c3)
3648
func NewMulti(contexts ...Context) Context {
3749
// Same rationale as for New/NewWithKey of using the builder instead of constructing directly.
3850
var m MultiBuilder

ldcontext/constructors_test.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,12 @@ func TestNewWithKindErrors(t *testing.T) {
3939
}
4040

4141
func TestNewMulti(t *testing.T) {
42-
c1 := NewWithKind("org", "my-org-key")
43-
c2 := NewWithKind("user", "my-user-key")
44-
c0 := NewMulti(c1, c2)
45-
46-
assert.Equal(t, NewMultiBuilder().Add(c1).Add(c2).Build(), c0)
42+
sub1 := NewWithKind("kind1", "key1")
43+
sub2 := NewWithKind("kind2", "key2")
44+
sub3 := NewWithKind("kind3", "key3")
45+
sub4 := NewWithKind("kind3", "key3")
46+
47+
assert.Equal(t, NewMultiBuilder().Add(sub1).Add(sub2).Build(), NewMulti(sub1, sub2))
48+
assert.Equal(t, NewMultiBuilder().Add(sub1).Add(sub2).Add(sub3).Add(sub4).Build(),
49+
NewMulti(sub1, NewMulti(sub2, sub3), sub4))
4750
}

ldcontext/context.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ import (
1111

1212
// Context is a collection of attributes that can be referenced in flag evaluations and analytics events.
1313
//
14-
// (TKTK - some conceptual text here, and/or a link to a docs page)
15-
//
1614
// To create a Context of a single kind, such as a user, you may use the New() or NewWithKind()
1715
// constructors. Or, to specify other attributes, use NewBuilder().
1816
//

ldcontext/context_easyjson.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,9 @@ func unmarshalOldUserSchemaEasyJSON(c *Context, in *jlexer.Lexer, usingEventForm
290290
} else {
291291
var value ldvalue.Value
292292
value.UnmarshalEasyJSON(in)
293-
attributes.Set(name, value)
293+
if isOldUserCustomAttributeNameAllowed(name) {
294+
attributes.Set(name, value)
295+
}
294296
}
295297
in.WantComma()
296298
}

ldcontext/context_unmarshaling.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88
"github.com/launchdarkly/go-jsonstream/v2/jreader"
99
)
1010

11-
// See internalAttributeNameIfPossible().
11+
// See internAttributeNameIfPossible().
1212
var internCommonAttributeNamesMap = makeInternCommonAttributeNamesMap() //nolint:gochecknoglobals
1313

1414
func makeInternCommonAttributeNamesMap() map[string]string {
@@ -24,9 +24,6 @@ func makeInternCommonAttributeNamesMap() map[string]string {
2424
// LaunchDarkly's JSON schema for contexts is standardized across SDKs. For unmarshaling, there are
2525
// three supported formats:
2626
//
27-
// (TKTK: consider moving all this content into a non-platform-specific online docs page, since none
28-
// of this is specific to Go)
29-
//
3027
// 1. A single-kind context, identified by a top-level "kind" property that is not "multi".
3128
//
3229
// 2. A multi-kind context, identified by a top-level "kind" property of "multi".
@@ -200,7 +197,9 @@ func unmarshalOldUserSchema(c *Context, r *jreader.Reader, usingEventFormat bool
200197
name := string(customObj.Name())
201198
var value ldvalue.Value
202199
value.ReadFromJSONReader(r)
203-
b.SetValue(name, value)
200+
if isOldUserCustomAttributeNameAllowed(name) {
201+
b.SetValue(name, value)
202+
}
204203
}
205204
case jsonPropOldUserPrivate:
206205
if usingEventFormat {
@@ -236,6 +235,17 @@ func unmarshalOldUserSchema(c *Context, r *jreader.Reader, usingEventFormat bool
236235
return c.Err()
237236
}
238237

238+
func isOldUserCustomAttributeNameAllowed(name string) bool {
239+
// If we see any of these names within the "custom": {} object in old-style user JSON, logically
240+
// we can't use it because it would collide with a top-level property.
241+
switch name {
242+
case ldattr.KindAttr, ldattr.KeyAttr, ldattr.NameAttr, ldattr.AnonymousAttr, jsonPropMeta:
243+
return false
244+
default:
245+
return true
246+
}
247+
}
248+
239249
// internAttributeNameIfPossible takes a byte slice representing a property name, and returns an existing
240250
// string if we already have a string literal equal to that name; otherwise it converts the bytes to a string.
241251
//

0 commit comments

Comments
 (0)