Skip to content

Commit 4a33949

Browse files
authored
feat(sidekick): parse discovery doc path templates (#2241)
Discovery doc path templates are specified using URI templates, that is RFC 6570 templates. This is not the same format as the HTTP annotations for Protobuf. Compute only uses a **subset** of the URI template spec, which allows us to parse the strings and use (without change) the `api.PathTemplate` structure. If compute starts using more complex path templates we can grow the data structures at that time.
1 parent 58336ac commit 4a33949

File tree

2 files changed

+257
-0
lines changed

2 files changed

+257
-0
lines changed
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package discovery
16+
17+
import (
18+
"fmt"
19+
"regexp"
20+
"strings"
21+
22+
"github.com/googleapis/librarian/internal/sidekick/internal/api"
23+
)
24+
25+
const (
26+
beginExpression = '{'
27+
endExpression = '}'
28+
slash = '/'
29+
)
30+
31+
var (
32+
identifierRe = regexp.MustCompile("[A-Za-z][A-Za-z0-9_]*")
33+
)
34+
35+
// ParseUriTemplate parses a [RFC 6570] URI template as an `api.PathTemplate`.
36+
//
37+
// In sidekick we need to capture the structure of the URI template for the
38+
// codec(s) to emit good templates with them.
39+
func ParseUriTemplate(uriTemplate string) (*api.PathTemplate, error) {
40+
template := &api.PathTemplate{}
41+
var pos int
42+
for {
43+
var err error
44+
var segment *api.PathSegment
45+
var width int
46+
47+
if pos == len(uriTemplate) {
48+
return nil, fmt.Errorf("expected a segment, found eof: %s", uriTemplate)
49+
}
50+
if uriTemplate[pos] == beginExpression {
51+
segment, width, err = parseExpression(uriTemplate[pos:])
52+
} else {
53+
segment, width, err = parseLiteral(uriTemplate[pos:])
54+
}
55+
if err != nil {
56+
return nil, err
57+
}
58+
template.Segments = append(template.Segments, *segment)
59+
pos += width
60+
if pos == len(uriTemplate) || uriTemplate[pos] != slash {
61+
break
62+
}
63+
pos++ // Skip slash
64+
}
65+
if pos != len(uriTemplate) {
66+
return nil, fmt.Errorf("trailing data (%q) cannot be parsed as a URI template", uriTemplate[pos:])
67+
}
68+
return template, nil
69+
}
70+
71+
func parseExpression(input string) (*api.PathSegment, int, error) {
72+
if input == "" || input[0] != beginExpression {
73+
return nil, 0, fmt.Errorf("missing `{` character in expression %q", input)
74+
}
75+
tail := input[1:]
76+
if strings.IndexAny(tail, "+#") == 0 {
77+
return nil, 0, fmt.Errorf("level 2 expressions unsupported input=%q", input)
78+
}
79+
if strings.IndexAny(tail, "./?&") == 0 {
80+
return nil, 0, fmt.Errorf("level 3 expressions unsupported input=%q", input)
81+
}
82+
if strings.IndexAny(tail, "=,!@|") == 0 {
83+
return nil, 0, fmt.Errorf("reserved character on expression %q", input)
84+
}
85+
match := identifierRe.FindStringIndex(tail)
86+
if match[0] != 0 {
87+
return nil, 0, fmt.Errorf("no identifier found on expression %q", input)
88+
}
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)
93+
}
94+
return &api.PathSegment{Variable: api.NewPathVariable(id)}, match[1] + 2, nil
95+
}
96+
97+
// parseLiteral() extracts a literal value from `input`.
98+
//
99+
// The format for literals is defined in:
100+
//
101+
// https://www.rfc-editor.org/rfc/rfc6570.html#section-2.1
102+
//
103+
// We simplify the parsing a bit assuming most discovery docs contain valid
104+
// URI templates.
105+
func parseLiteral(input string) (*api.PathSegment, int, error) {
106+
index := strings.IndexAny(input, " \"'<>\\^`{|}/")
107+
var literal string
108+
var tail string
109+
var width int
110+
if index == -1 {
111+
literal = input
112+
tail = ""
113+
width = len(input)
114+
} else {
115+
literal = input[:index]
116+
tail = input[index:]
117+
width = index
118+
}
119+
if literal == "" {
120+
return nil, 0, fmt.Errorf("invalid empty literal with input=%q", input)
121+
}
122+
if tail != "" && tail[0] != slash {
123+
return nil, index, fmt.Errorf("found unexpected character %v in literal %q, stopped at position %v", tail[0], input, index)
124+
}
125+
return &api.PathSegment{Literal: &literal}, width, nil
126+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package discovery
16+
17+
import (
18+
"testing"
19+
20+
"github.com/google/go-cmp/cmp"
21+
"github.com/google/go-cmp/cmp/cmpopts"
22+
"github.com/googleapis/librarian/internal/sidekick/internal/api"
23+
)
24+
25+
func TestParseUriTemplateSuccess(t *testing.T) {
26+
for _, test := range []struct {
27+
input string
28+
want *api.PathTemplate
29+
}{
30+
{"locations/global/firewallPolicies", api.NewPathTemplate().WithLiteral("locations").WithLiteral("global").WithLiteral("firewallPolicies")},
31+
{"locations/global/operations/{operation}", api.NewPathTemplate().WithLiteral("locations").WithLiteral("global").WithLiteral("operations").WithVariable(api.NewPathVariable("operation"))},
32+
{"projects/{project}/zones/{zone}/{parentName}/reservationSubBlocks", api.NewPathTemplate().WithLiteral("projects").WithVariable(api.NewPathVariable("project")).WithLiteral("zones").WithVariable(api.NewPathVariable("zone")).WithVariable(api.NewPathVariable("parentName")).WithLiteral("reservationSubBlocks")},
33+
} {
34+
got, err := ParseUriTemplate(test.input)
35+
if err != nil {
36+
t.Errorf("expected a successful parse with input=%s, err=%v", test.input, err)
37+
continue
38+
}
39+
if diff := cmp.Diff(test.want, got, cmpopts.EquateEmpty()); diff != "" {
40+
t.Errorf("mismatch [%s] (-want, +got):\n%s", test.input, diff)
41+
}
42+
}
43+
}
44+
45+
func TestParseUriTemplateError(t *testing.T) {
46+
for _, test := range []struct {
47+
input string
48+
}{
49+
{"v1/{+parent}/externalAccountKeys"},
50+
{"a/b/c/"},
51+
{"a/b/c|"},
52+
{"a/b/{c}|"},
53+
{"a/b/{c}}/d"},
54+
{"a/b/{c}}"},
55+
{"a/b/{c}/"},
56+
{"{foo}}bar"},
57+
} {
58+
if got, err := ParseUriTemplate(test.input); err == nil {
59+
t.Errorf("expected a parsing error with input=%s, got=%v", test.input, got)
60+
}
61+
}
62+
}
63+
64+
func TestParseExpression(t *testing.T) {
65+
for _, test := range []struct {
66+
input string
67+
want string
68+
}{
69+
{"{abc}", "abc"},
70+
{"{Abc}", "Abc"},
71+
{"{abc012}", "abc012"},
72+
{"{abc_012}", "abc_012"},
73+
{"{abc_012}/foo/{bar}", "abc_012"},
74+
} {
75+
gotSegment, gotWidth, err := parseExpression(test.input)
76+
if err != nil {
77+
t.Errorf("expected a successful parse with input=%s, err=%v", test.input, err)
78+
continue
79+
}
80+
if diff := cmp.Diff(&api.PathSegment{Variable: api.NewPathVariable(test.want)}, gotSegment); diff != "" {
81+
t.Errorf("mismatch [%s] (-want, +got):\n%s", test.input, diff)
82+
}
83+
if len(test.want)+2 != gotWidth {
84+
t.Errorf("mismatch want=%d, got=%d", len(test.want), gotWidth)
85+
}
86+
}
87+
}
88+
89+
func TestParseExpressionError(t *testing.T) {
90+
for _, input := range []string{
91+
"", "(a)",
92+
"{+a}", "{#a}",
93+
"{.a}", "{/a}", "{?a}", "{&a}",
94+
"{=a}", "{,a}", "{!a}", "{@a}", "{|a}",
95+
"{a,b}", "{_abc}", "{0abc}", "{ab"} {
96+
if gotSegment, gotWidth, err := parseExpression(input); err == nil {
97+
t.Errorf("expected a parsing error with input=%s, gotSegment=%v, gotWidth=%v", input, gotSegment, gotWidth)
98+
}
99+
}
100+
}
101+
102+
func TestParseLiteral(t *testing.T) {
103+
for _, test := range []struct {
104+
input string
105+
want string
106+
}{
107+
{"abc/def", "abc"},
108+
{"abcde/f", "abcde"},
109+
{"abcdef", "abcdef"},
110+
} {
111+
gotSegment, gotWidth, err := parseLiteral(test.input)
112+
if err != nil {
113+
t.Errorf("expected a successful parse with input=%s, err=%v", test.input, err)
114+
continue
115+
}
116+
if diff := cmp.Diff(&api.PathSegment{Literal: &test.want}, gotSegment); diff != "" {
117+
t.Errorf("mismatch [%s] (-want, +got):\n%s", test.input, diff)
118+
}
119+
if len(test.want) != gotWidth {
120+
t.Errorf("mismatch want=%d, got=%d", len(test.want), gotWidth)
121+
}
122+
}
123+
}
124+
125+
func TestParseLiteralError(t *testing.T) {
126+
for _, input := range []string{"", "^", "'", "/", "abc^"} {
127+
if gotSegment, gotWidth, err := parseLiteral(input); err == nil {
128+
t.Errorf("expected a parsing error with input=%s, gotSegment=%v, gotWidth=%v", input, gotSegment, gotWidth)
129+
}
130+
}
131+
}

0 commit comments

Comments
 (0)