Skip to content

Commit 816cdd0

Browse files
authored
feat(sidekick): RFC 6570 "+" support (#3538)
Add support for the "+" operator defined as part of RFC-6570 level 2 templates. Part of #2820
1 parent 3104371 commit 816cdd0

4 files changed

Lines changed: 51 additions & 17 deletions

File tree

internal/sidekick/api/model.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,9 @@ type PathSegment struct {
668668
type PathVariable struct {
669669
FieldPath []string
670670
Segments []string
671+
// Allow characters defined as `reserved` by RFC-6570 1.5 to pass through without
672+
// percent encoding. See RFC-6570 1.2 for examples.
673+
AllowReserved bool
671674
}
672675

673676
// PathMatch is a single wildcard match in a path.
@@ -729,6 +732,12 @@ func (v *PathVariable) WithMatch() *PathVariable {
729732
return v
730733
}
731734

735+
// WithAllowReserved marks the variable as allowing reserved characters to remain unescaped.
736+
func (v *PathVariable) WithAllowReserved() *PathVariable {
737+
v.AllowReserved = true
738+
return v
739+
}
740+
732741
// NewPathSegment creates a new path segment.
733742
func NewPathSegment() *PathSegment {
734743
return &PathSegment{}

internal/sidekick/api/model_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ func TestPathTemplateBuilder(t *testing.T) {
201201
WithVariable(NewPathVariable("parent", "child").
202202
WithLiteral("projects").
203203
WithMatch().
204+
WithAllowReserved().
204205
WithLiteral("locations").
205206
WithMatchRecursive()).
206207
WithVariableNamed("v2", "field").
@@ -214,8 +215,9 @@ func TestPathTemplateBuilder(t *testing.T) {
214215
},
215216
{
216217
Variable: &PathVariable{
217-
FieldPath: []string{"parent", "child"},
218-
Segments: []string{"projects", "*", "locations", "**"},
218+
FieldPath: []string{"parent", "child"},
219+
Segments: []string{"projects", "*", "locations", "**"},
220+
AllowReserved: true,
219221
},
220222
},
221223
{

internal/sidekick/parser/discovery/uritemplate.go

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const (
2929
)
3030

3131
var (
32-
identifierRe = regexp.MustCompile("[A-Za-z][A-Za-z0-9_]*")
32+
identifierRe = regexp.MustCompile("^[A-Za-z][A-Za-z0-9_]*$")
3333
)
3434

3535
// ParseUriTemplate parses a [RFC 6570] URI template as an `api.PathTemplate`.
@@ -73,25 +73,40 @@ func parseExpression(input string) (*api.PathSegment, int, error) {
7373
return nil, 0, fmt.Errorf("missing `{` character in expression %q", input)
7474
}
7575
tail := input[1:]
76-
if strings.IndexAny(tail, "+#") == 0 {
77-
return nil, 0, fmt.Errorf("level 2 expressions unsupported input=%q", input)
76+
end := strings.IndexRune(tail, endExpression)
77+
if end == -1 {
78+
return nil, 0, fmt.Errorf("missing `}` character at the end of the expression %q", input)
79+
}
80+
tail = tail[:end]
81+
if tail == "" {
82+
return nil, 0, fmt.Errorf("empty expression %q", input)
7883
}
84+
7985
if strings.IndexAny(tail, "./?&") == 0 {
8086
return nil, 0, fmt.Errorf("level 3 expressions unsupported input=%q", input)
8187
}
8288
if strings.IndexAny(tail, "=,!@|") == 0 {
8389
return nil, 0, fmt.Errorf("reserved character on expression %q", input)
8490
}
85-
match := identifierRe.FindStringIndex(tail)
86-
if match[0] != 0 {
87-
return nil, 0, fmt.Errorf("no identifier found on expression %q", input)
91+
92+
allowReserved := false
93+
switch tail[0] {
94+
case '+':
95+
allowReserved = true
96+
tail = tail[1:]
97+
case '#':
98+
return nil, 0, fmt.Errorf("level 2 fragment expressions unsupported input=%q", input)
8899
}
89-
id := tail[0:match[1]]
90-
tail = tail[match[1]:]
91-
if tail == "" || tail[0] != endExpression {
92-
return nil, 0, fmt.Errorf("missing `}` character at the end of the expression %q", input)
100+
101+
match := identifierRe.MatchString(tail)
102+
if !match {
103+
return nil, 0, fmt.Errorf("invalid varname found on expression %q", input)
104+
}
105+
variable := api.NewPathVariable(tail).WithMatch()
106+
if allowReserved {
107+
variable.WithAllowReserved()
93108
}
94-
return &api.PathSegment{Variable: api.NewPathVariable(id).WithMatch()}, match[1] + 2, nil
109+
return &api.PathSegment{Variable: variable}, end + 2, nil
95110
}
96111

97112
// parseLiteral() extracts a literal value from `input`.

internal/sidekick/parser/discovery/uritemplate_test.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ func TestParseUriTemplateSuccess(t *testing.T) {
4343
WithVariableNamed("zone").
4444
WithVariableNamed("parentName").
4545
WithLiteral("reservationSubBlocks")},
46+
{"v1/{+parent}/externalAccountKeys", api.NewPathTemplate().
47+
WithLiteral("v1").
48+
WithVariable(api.NewPathVariable("parent").WithAllowReserved().WithMatch()).
49+
WithLiteral("externalAccountKeys")},
4650
} {
4751
got, err := ParseUriTemplate(test.input)
4852
if err != nil {
@@ -59,7 +63,6 @@ func TestParseUriTemplateError(t *testing.T) {
5963
for _, test := range []struct {
6064
input string
6165
}{
62-
{"v1/{+parent}/externalAccountKeys"},
6366
{"a/b/c/"},
6467
{"a/b/c|"},
6568
{"a/b/{c}|"},
@@ -101,11 +104,16 @@ func TestParseExpression(t *testing.T) {
101104

102105
func TestParseExpressionError(t *testing.T) {
103106
for _, input := range []string{
104-
"", "(a)",
105-
"{+a}", "{#a}",
107+
"",
108+
"{}",
109+
"{+}",
110+
"{#}",
111+
"(a)",
112+
"{#a}",
106113
"{.a}", "{/a}", "{?a}", "{&a}",
107114
"{=a}", "{,a}", "{!a}", "{@a}", "{|a}",
108-
"{a,b}", "{_abc}", "{0abc}", "{ab"} {
115+
"{a,b}", "{_abc}", "{0abc}", "{ab", "{abc/}",
116+
"{+abc", "{+abc/}"} {
109117
if gotSegment, gotWidth, err := parseExpression(input); err == nil {
110118
t.Errorf("expected a parsing error with input=%s, gotSegment=%v, gotWidth=%v", input, gotSegment, gotWidth)
111119
}

0 commit comments

Comments
 (0)