Skip to content

Commit a4e733b

Browse files
committed
merge prerelease branch
1 parent 9163d8a commit a4e733b

20 files changed

+521
-249
lines changed

ldattr/errors.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
)
66

77
var (
8-
errAttributeEmpty = errors.New("attribute reference cannot be empty")
9-
errAttributeExtraSlash = errors.New("attribute reference contained a double slash or a trailing slash")
8+
errAttributeEmpty = errors.New("attribute reference cannot be empty")
9+
errAttributeExtraSlash = errors.New("attribute reference contained a double slash or a trailing slash")
10+
errAttributeInvalidEscape = errors.New(
11+
"attribute reference contained an escape character (~) that was not followed by 0 or 1")
1012
)

ldattr/package.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,7 @@
33
// This includes the ldattr.Ref type, which provides a syntax similar to JSON Pointer for
44
// referencing values either of a top-level context attribute, or of a value within a JSON object
55
// or JSON array. It also includes constants for the names of some built-in attributes.
6+
//
7+
// These types and constants are mainly intended to be used internally by LaunchDarkly SDK and
8+
// service code. Applications are unlikely to need to use them directly.
69
package ldattr

ldattr/ref.go

Lines changed: 104 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,55 @@ import (
1212

1313
// Ref is an attribute name or path expression identifying a value within a Context.
1414
//
15-
// This can be used to retrieve a value with Context.GetValueForRef(), or to identify an attribute or
15+
// This type is mainly intended to be used internally by LaunchDarkly SDK and service code, where
16+
// efficiency is a major concern so it's desirable to do any parsing or preprocessing just once.
17+
// Applications are unlikely to need to use the Ref type directly.
18+
//
19+
// It can be used to retrieve a value with Context.GetValueForRef(), or to identify an attribute or
1620
// nested value that should be considered private with Builder.Private() (the SDK configuration can also
1721
// have a list of private attribute references).
1822
//
19-
// This is represented as a separate type, rather than just a string, so that validation and parsing can
20-
// be done ahead of time if an attribute reference will be used repeatedly later (such as in flag
21-
// evaluations).
23+
// Parsing and validation are done at the time that the NewRef or NewLiteralRef constructor is called.
24+
// If a Ref instance was created from an invalid string, or if it is an uninitialized Ref{}, it is
25+
// considered invalid and its Err() method will return a non-nil error.
26+
//
27+
// Syntax
28+
//
29+
// The string representation of an attribute reference in LaunchDarkly JSON data uses the following
30+
// syntax:
31+
//
32+
// If the first character is not a slash, the string is interpreted literally as an attribute name.
33+
// An attribute name can contain any characters, but must not be empty.
34+
//
35+
// If the first character is a slash, the string is interpreted as a slash-delimited path where the
36+
// first path component is an attribute name, and each subsequent path component is either the name of
37+
// a property in a JSON object, or a decimal numeric string that is the index of an element in a JSON
38+
// array. Any instances of the characters "/" or "~" in a path component are escaped as "~1" or "~0"
39+
// respectively. This syntax deliberately resembles JSON Pointer, but no JSON Pointer behaviors other
40+
// than those mentioned here are supported.
2241
//
23-
// Call NewRef() to create an Ref. An uninitialized ldattr.Ref{} struct is not valid for use in any
24-
// SDK operations. Also, an Ref can be in an error state if it was built from an invalid string. See
25-
// Ref.Err().
42+
// Examples
43+
//
44+
// Suppose there is a context whose JSON implementation looks like this:
45+
//
46+
// {
47+
// "kind": "user",
48+
// "key": "value1",
49+
// "address": {
50+
// "street": "value2",
51+
// "city": "value3"
52+
// },
53+
// "groups": [ "value4", "value5" ],
54+
// "good/bad": "value6"
55+
// }
56+
//
57+
// The attribute references "key" and "/key" would both point to "value1".
58+
//
59+
// The attribute reference "/address/street" would point to "value2".
60+
//
61+
// The attribute reference "/groups/0" would point to "value4".
62+
//
63+
// The attribute references "good/bad" and "/good~1bad" would both point to "value6".
2664
type Ref struct {
2765
err error
2866
rawPath string
@@ -35,38 +73,12 @@ type attrRefComponent struct {
3573
intValue ldvalue.OptionalInt
3674
}
3775

38-
// NewRef creates a Ref from a string.
39-
//
40-
// If the string starts with '/', then this is treated as a slash-delimited path reference where the
41-
// first component is the name of an attribute, and subsequent components are the names of nested JSON
42-
// object properties (or, if they are numeric, the indices of JSON array elements). In this syntax, the
43-
// escape sequences "~0" and "~1" represent '~' and '/' respectively within a path component.
44-
//
45-
// If the string does not start with '/', then it is treated as the literal name of an attribute (the
46-
// same as NewNameRef).
47-
//
48-
// For instance, if the JSON representation of a context is as follows--
49-
//
50-
// {
51-
// "kind": "user",
52-
// "key": "123",
53-
// "name": "xyz",
54-
// "address": {
55-
// "street": "99 Main St.",
56-
// "city": "Westview"
57-
// },
58-
// "groups": [ "p", "q" ],
59-
// "a/b": "ok"
60-
// }
61-
//
62-
// --then NewRef("name") or NewRef("/name") would refer to the value "xyz"; NewRef("/address/street")
63-
// would refer to the value "99 Main St."; NewRef("/groups/0") would refer to the value "p"; and
64-
// NewRef("a/b") or NewRef("/a~1b") would refer to the value "ok".
76+
// NewRef creates a Ref from a string. For the supported syntax and examples, see comments on the Ref type.
6577
//
6678
// This constructor always returns a Ref that preserves the original string, even if validation fails,
6779
// so that calling String() (or serializing the Ref to JSON) will produce the original string. If
68-
// validation fails, Err() will return nil and any SDK method that takes this Ref as a parameter will
69-
// consider it invalid.
80+
// validation fails, Err() will return a non-nil error and any SDK method that takes this Ref as a
81+
// parameter will consider it invalid.
7082
func NewRef(referenceString string) Ref {
7183
if referenceString == "" || referenceString == "/" {
7284
return Ref{err: errAttributeEmpty, rawPath: referenceString}
@@ -79,7 +91,10 @@ func NewRef(referenceString string) Ref {
7991
if !strings.Contains(path, "/") {
8092
// There's only one segment, so this is still a simple attribute reference. However, we still may
8193
// need to unescape special characters.
82-
return Ref{singlePathComponent: unescapePath(path), rawPath: referenceString}
94+
if unescaped, ok := unescapePath(path); ok {
95+
return Ref{singlePathComponent: unescaped, rawPath: referenceString}
96+
}
97+
return Ref{err: errAttributeInvalidEscape, rawPath: referenceString}
8398
}
8499
parts := strings.Split(path, "/")
85100
ret := Ref{rawPath: referenceString, components: make([]attrRefComponent, 0, len(parts))}
@@ -88,7 +103,11 @@ func NewRef(referenceString string) Ref {
88103
ret.err = errAttributeExtraSlash
89104
return ret
90105
}
91-
component := attrRefComponent{name: unescapePath(p)}
106+
unescaped, ok := unescapePath(p)
107+
if !ok {
108+
return Ref{err: errAttributeInvalidEscape, rawPath: referenceString}
109+
}
110+
component := attrRefComponent{name: unescaped}
92111
if p[0] >= '0' && p[0] <= '9' {
93112
if n, err := strconv.Atoi(p); err == nil {
94113
component.intValue = ldvalue.NewOptionalInt(n)
@@ -99,11 +118,16 @@ func NewRef(referenceString string) Ref {
99118
return ret
100119
}
101120

102-
// NewNameRef is similar to NewRef except that it always interprets the string as a single
121+
// NewLiteralRef is similar to NewRef except that it always interprets the string as a literal
103122
// attribute name, never as a slash-delimited path expression. There is no escaping or unescaping,
104-
// even if the name contains literal '/' or '~' characters. This method always returns a valid Ref
105-
// unless the name is empty.
106-
func NewNameRef(attrName string) Ref {
123+
// even if the name contains literal '/' or '~' characters. Since an attribute name can contain
124+
// any characters, this method always returns a valid Ref unless the name is empty.
125+
//
126+
// For example: ldattr.NewLiteralRef("name") is exactly equivalent to ldattr.NewRef("name").
127+
// ldattr.NewLiteralRef("a/b") is exactly equivalent to ldattr.NewRef("a/b") (since the syntax
128+
// used by NewRef treats the whole string as a literal as long as it does not start with a slash),
129+
// or to ldattr.NewRef("/a~1b").
130+
func NewLiteralRef(attrName string) Ref {
107131
if attrName == "" {
108132
return Ref{err: errAttributeEmpty, rawPath: attrName}
109133
}
@@ -140,13 +164,17 @@ func (a Ref) Equal(other Ref) bool {
140164
//
141165
// A Ref can only be invalid for the following reasons:
142166
//
143-
// - The input string was empty, or consisted only of "/".
167+
// 1. The input string was empty, or consisted only of "/".
168+
//
169+
// 2. A slash-delimited string had a double slash causing one component to be empty, such as "/a//b".
144170
//
145-
// - A slash-delimited string had a double slash causing one component to be empty, such as "/a//b".
171+
// 3. A slash-delimited string contained a "~" character that was not followed by "0" or "1".
146172
//
147173
// Otherwise, the Ref is valid, but that does not guarantee that such an attribute exists in any
148174
// given Context. For instance, NewRef("name") is a valid Ref, but a specific Context might or might
149175
// not have a name.
176+
//
177+
// See comments on the Ref type for more details of the attribute reference syntax.
150178
func (a Ref) Err() error {
151179
if a.err == nil && a.rawPath == "" {
152180
return errAttributeEmpty
@@ -237,10 +265,39 @@ func (a *Ref) UnmarshalJSON(data []byte) error {
237265
return nil
238266
}
239267

240-
func unescapePath(path string) string {
268+
// Performs unescaping of attribute reference path components:
269+
//
270+
// "~1" becomes "/"
271+
// "~0" becomes "~"
272+
// "~" followed by any character other than "0" or "1" is invalid
273+
//
274+
// The second return value is true if successful, or false if there was an invalid escape sequence.
275+
func unescapePath(path string) (string, bool) {
241276
// If there are no tildes then there's definitely nothing to do
242277
if !strings.Contains(path, "~") {
243-
return path
278+
return path, true
279+
}
280+
out := make([]byte, 0, 100) // arbitrary preallocated size - path components will almost always be shorter than this
281+
for i := 0; i < len(path); i++ {
282+
ch := path[i]
283+
if ch != '~' {
284+
out = append(out, ch)
285+
continue
286+
}
287+
i++
288+
if i >= len(path) {
289+
return "", false
290+
}
291+
var unescaped byte
292+
switch path[i] {
293+
case '0':
294+
unescaped = '~'
295+
case '1':
296+
unescaped = '/'
297+
default:
298+
return "", false
299+
}
300+
out = append(out, unescaped)
244301
}
245-
return strings.ReplaceAll(strings.ReplaceAll(path, "~1", "/"), "~0", "~")
302+
return string(out), true
246303
}

ldattr/ref_test.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ func TestRefInvalid(t *testing.T) {
2020
{"//", errAttributeExtraSlash},
2121
{"/a//b", errAttributeExtraSlash},
2222
{"/a/b/", errAttributeExtraSlash},
23+
{"/a~x", errAttributeInvalidEscape},
24+
{"/a~", errAttributeInvalidEscape},
25+
{"/a/b~x", errAttributeInvalidEscape},
26+
{"/a/b~", errAttributeInvalidEscape},
2327
} {
2428
t.Run(fmt.Sprintf("input string %q", p.input), func(t *testing.T) {
2529
a := NewRef(p.input)
@@ -69,7 +73,7 @@ func TestRefSimpleWithLeadingSlash(t *testing.T) {
6973
{"/name", "name"},
7074
{"/custom", "custom"},
7175
{"/0", "0"},
72-
{"/name~1with~1slashes~0and~0tildes~2~x~~", "name/with/slashes~and~tildes~2~x~~"},
76+
{"/name~1with~1slashes~0and~0tildes", "name/with/slashes~and~tildes"},
7377
} {
7478
t.Run(fmt.Sprintf("input string %q", params.input), func(t *testing.T) {
7579
a := NewRef(params.input)
@@ -83,22 +87,22 @@ func TestRefSimpleWithLeadingSlash(t *testing.T) {
8387
}
8488
}
8589

86-
func TestNewNameRef(t *testing.T) {
87-
a0 := NewNameRef("name")
90+
func TestNewLiteralRef(t *testing.T) {
91+
a0 := NewLiteralRef("name")
8892
assert.Equal(t, NewRef("name"), a0)
8993

90-
a1 := NewNameRef("a/b")
94+
a1 := NewLiteralRef("a/b")
9195
assert.Equal(t, NewRef("a/b"), a1)
9296

93-
a2 := NewNameRef("/a/b~c")
97+
a2 := NewLiteralRef("/a/b~c")
9498
assert.Equal(t, NewRef("/~1a~1b~0c"), a2)
9599
assert.Equal(t, 1, a2.Depth())
96100

97-
a3 := NewNameRef("/")
101+
a3 := NewLiteralRef("/")
98102
assert.Equal(t, NewRef("/~1"), a3)
99103
assert.Equal(t, 1, a3.Depth())
100104

101-
a4 := NewNameRef("")
105+
a4 := NewLiteralRef("")
102106
assert.Equal(t, errAttributeEmpty, a4.Err())
103107
}
104108

@@ -117,6 +121,7 @@ func TestRefComponents(t *testing.T) {
117121
{"/a/b", 2, 0, "a", undefined},
118122
{"/a/b", 2, 1, "b", undefined},
119123
{"/a~1b/c", 2, 0, "a/b", undefined},
124+
{"/a~0b/c", 2, 0, "a~b", undefined},
120125
{"/a/10/20/30x", 4, 1, "10", 10},
121126
{"/a/10/20/30x", 4, 2, "20", 20},
122127
{"/a/10/20/30x", 4, 3, "30x", undefined},

ldcontext/builder_multi.go

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,23 @@ func NewMultiBuilder() *MultiBuilder {
4848
// to see if it has an error. See Context.Err() for more information about invalid Context
4949
// conditions. Using a single-return-value syntax is more convenient for application code, since
5050
// in normal usage an application will never build an invalid Context.
51+
//
52+
// If only one context kind was added to the builder, Build returns a single-kind context rather
53+
// than a multi-kind context.
5154
func (m *MultiBuilder) Build() Context {
5255
if len(m.contexts) == 0 {
5356
return Context{err: errContextKindMultiWithNoKinds}
5457
}
5558

59+
if len(m.contexts) == 1 {
60+
// Never return a multi-kind context with just one kind; instead return the individual one
61+
c := m.contexts[0]
62+
if c.Multiple() {
63+
return Context{err: errContextKindMultiWithinMulti}
64+
}
65+
return c
66+
}
67+
5668
m.contextsCopyOnWrite = true // see note on ___CopyOnWrite in Builder.Build()
5769

5870
// Sort the list by kind - this makes our output deterministic and will also be important when we
@@ -97,18 +109,14 @@ func (m *MultiBuilder) Build() Context {
97109
multiContexts: m.contexts,
98110
}
99111

100-
if len(m.contexts) == 1 && m.contexts[0].kind != DefaultKind {
101-
ret.fullyQualifiedKey = m.contexts[0].fullyQualifiedKey
102-
} else {
103-
// Fully-qualified key for multi-kind is defined as "kind1:key1:kind2:key2" etc., where kinds are in
104-
// alphabetical order (we have already sorted them above) and keys are URL-encoded. In this case we
105-
// do _not_ omit a default kind of "user".
106-
for _, c := range m.contexts {
107-
if ret.fullyQualifiedKey != "" {
108-
ret.fullyQualifiedKey += ":"
109-
}
110-
ret.fullyQualifiedKey += makeFullyQualifiedKeySingleKind(c.kind, c.key, false)
112+
// Fully-qualified key for multi-kind is defined as "kind1:key1:kind2:key2" etc., where kinds are in
113+
// alphabetical order (we have already sorted them above) and keys are URL-encoded. In this case we
114+
// do _not_ omit a default kind of "user".
115+
for _, c := range m.contexts {
116+
if ret.fullyQualifiedKey != "" {
117+
ret.fullyQualifiedKey += ":"
111118
}
119+
ret.fullyQualifiedKey += makeFullyQualifiedKeySingleKind(c.kind, c.key, false)
112120
}
113121

114122
return ret

0 commit comments

Comments
 (0)