Skip to content

Commit e236c7d

Browse files
authored
feat(sidekick): discovery LRO support (#2542)
1 parent 215e5bd commit e236c7d

File tree

11 files changed

+535
-12
lines changed

11 files changed

+535
-12
lines changed

internal/sidekick/internal/api/model.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package api
1616

1717
import (
18+
"fmt"
1819
"slices"
1920
"strings"
2021
)
@@ -245,6 +246,8 @@ type Method struct {
245246
ServerSideStreaming bool
246247
// OperationInfo contains information for methods returning long-running operations.
247248
OperationInfo *OperationInfo
249+
// DiscoveryLro has a value if this is a discovery-style long-running operation.
250+
DiscoveryLro *DiscoveryLro
248251
// Routing contains the routing annotations, if any.
249252
Routing []*RoutingInfo
250253
// AutoPopulated contains the auto-populated (request_id) field, if any, as defined in
@@ -403,6 +406,14 @@ type OperationInfo struct {
403406
Codec any
404407
}
405408

409+
// DiscoveryLro contains old-style long-running operation descriptors.
410+
type DiscoveryLro struct {
411+
// The path parameters required by the polling operation.
412+
PollingPathParameters []string
413+
// Language specific annotations.
414+
Codec any
415+
}
416+
406417
// RoutingInfo contains normalized routing info.
407418
//
408419
// The routing information format is documented in:
@@ -489,6 +500,27 @@ type PathTemplate struct {
489500
Verb *string
490501
}
491502

503+
// FlatPath returns a simplified representation of the path template as a string.
504+
//
505+
// In the context of discovery LROs it is useful to get the path template as a
506+
// simplified string, such as "compute/v1/projects/{project}/zones/{zone}/instances".
507+
// The path can be matched against LRO prefixes and then mapped to the correct
508+
// poller RPC.
509+
func (template *PathTemplate) FlatPath() string {
510+
var buffer strings.Builder
511+
sep := ""
512+
for _, segment := range template.Segments {
513+
buffer.WriteString(sep)
514+
if segment.Literal != nil {
515+
buffer.WriteString(*segment.Literal)
516+
} else if segment.Variable != nil {
517+
buffer.WriteString(fmt.Sprintf("{%s}", strings.Join(segment.Variable.FieldPath, ".")))
518+
}
519+
sep = "/"
520+
}
521+
return buffer.String()
522+
}
523+
492524
// PathSegment is a segment of a path.
493525
type PathSegment struct {
494526
Literal *string

internal/sidekick/internal/api/model_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,3 +286,42 @@ func TestFieldTypePredicates(t *testing.T) {
286286
}
287287
}
288288
}
289+
290+
func TestFlatPath(t *testing.T) {
291+
for _, test := range []struct {
292+
Input *PathTemplate
293+
Want string
294+
}{
295+
{
296+
Input: NewPathTemplate(),
297+
Want: "",
298+
},
299+
{
300+
Input: NewPathTemplate().
301+
WithLiteral("projects").
302+
WithVariableNamed("project").
303+
WithLiteral("zones").
304+
WithVariableNamed("zone"),
305+
Want: "projects/{project}/zones/{zone}",
306+
},
307+
{
308+
Input: NewPathTemplate().
309+
WithLiteral("projects").
310+
WithVariableNamed("project").
311+
WithLiteral("global").
312+
WithLiteral("location"),
313+
Want: "projects/{project}/global/location",
314+
},
315+
{
316+
Input: NewPathTemplate().
317+
WithLiteral("projects").
318+
WithVariable(NewPathVariable("a", "b", "c").WithMatchRecursive()),
319+
Want: "projects/{a.b.c}",
320+
},
321+
} {
322+
got := test.Input.FlatPath()
323+
if got != test.Want {
324+
t.Errorf("mismatch want=%q, got=%q", test.Want, got)
325+
}
326+
}
327+
}

internal/sidekick/internal/parser/disco.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func ParseDisco(cfg *config.Config) (*api.API, error) {
4747
if err != nil {
4848
return nil, err
4949
}
50-
result, err := discovery.NewAPI(serviceConfig, contents)
50+
result, err := discovery.NewAPI(serviceConfig, contents, cfg)
5151
if err != nil {
5252
return nil, err
5353
}

internal/sidekick/internal/parser/discovery/discovery.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,13 @@ import (
2323
"strings"
2424

2525
"github.com/googleapis/librarian/internal/sidekick/internal/api"
26+
"github.com/googleapis/librarian/internal/sidekick/internal/config"
2627
"github.com/googleapis/librarian/internal/sidekick/internal/parser/svcconfig"
2728
"google.golang.org/genproto/googleapis/api/serviceconfig"
2829
)
2930

3031
// NewAPI parses the discovery doc in `contents` and returns the corresponding `api.API` model.
31-
func NewAPI(serviceConfig *serviceconfig.Service, contents []byte) (*api.API, error) {
32+
func NewAPI(serviceConfig *serviceconfig.Service, contents []byte, cfg *config.Config) (*api.API, error) {
3233
doc, err := newDiscoDocument(contents)
3334
if err != nil {
3435
return nil, err
@@ -98,6 +99,8 @@ func NewAPI(serviceConfig *serviceconfig.Service, contents []byte) (*api.API, er
9899
// output on each run.
99100
slices.SortStableFunc(result.Services, compareServices)
100101

102+
lroAnnotations(result, cfg)
103+
101104
return result, nil
102105
}
103106

internal/sidekick/internal/parser/discovery/discovery_test.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,13 @@ import (
2424
"github.com/google/go-cmp/cmp/cmpopts"
2525
"github.com/googleapis/librarian/internal/sidekick/internal/api"
2626
"github.com/googleapis/librarian/internal/sidekick/internal/api/apitest"
27+
"github.com/googleapis/librarian/internal/sidekick/internal/config"
2728
"github.com/googleapis/librarian/internal/sidekick/internal/sample"
2829
"google.golang.org/genproto/googleapis/api/serviceconfig"
2930
)
3031

32+
const computeDiscoveryFile = "../../../testdata/disco/compute.v1.json"
33+
3134
func TestSorted(t *testing.T) {
3235
got, err := ComputeDisco(t, nil)
3336
if err != nil {
@@ -118,7 +121,7 @@ func TestBadParse(t *testing.T) {
118121
{"resource with bad child", `{"resources": {"badResource": {"resources": {"badChild": {"methods": {"badResponse": {"response": {"$ref": "notThere"}}}}}}}}`},
119122
} {
120123
contents := []byte(test.Contents)
121-
if _, err := NewAPI(nil, contents); err == nil {
124+
if _, err := NewAPI(nil, contents, nil); err == nil {
122125
t.Fatalf("expected error for %s input", test.Name)
123126
}
124127
}
@@ -210,7 +213,7 @@ func TestMessageErrors(t *testing.T) {
210213
{"bad message field", `{"schemas": {"withBadField": {"type": "object", "properties": {"badFormat": {"type": "string", "format": "--bad--"}}}}}`},
211214
} {
212215
contents := []byte(test.Contents)
213-
if _, err := NewAPI(nil, contents); err == nil {
216+
if _, err := NewAPI(nil, contents, nil); err == nil {
214217
t.Fatalf("expected error for %s input", test.Name)
215218
}
216219
}
@@ -224,17 +227,26 @@ func TestServiceErrors(t *testing.T) {
224227
{"bad method", `{"resources": {"withBadMethod": {"methods": {"uploadNotSupported": { "mediaUpload": {} }}}}}`},
225228
} {
226229
contents := []byte(test.Contents)
227-
if got, err := NewAPI(nil, contents); err == nil {
230+
if got, err := NewAPI(nil, contents, nil); err == nil {
228231
t.Fatalf("expected error for %s input, got=%v", test.Name, got)
229232
}
230233
}
231234
}
232235

233236
func ComputeDisco(t *testing.T, sc *serviceconfig.Service) (*api.API, error) {
234237
t.Helper()
235-
contents, err := os.ReadFile("../../../testdata/disco/compute.v1.json")
238+
contents, err := os.ReadFile(computeDiscoveryFile)
239+
if err != nil {
240+
t.Fatal(err)
241+
}
242+
return NewAPI(sc, contents, nil)
243+
}
244+
245+
func ComputeDiscoWithLros(t *testing.T, cfg *config.Config) (*api.API, error) {
246+
t.Helper()
247+
contents, err := os.ReadFile(computeDiscoveryFile)
236248
if err != nil {
237249
return nil, err
238250
}
239-
return NewAPI(sc, contents)
251+
return NewAPI(nil, contents, cfg)
240252
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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+
"strings"
20+
21+
"github.com/googleapis/librarian/internal/sidekick/internal/api"
22+
"github.com/googleapis/librarian/internal/sidekick/internal/config"
23+
)
24+
25+
func lroAnnotations(model *api.API, cfg *config.Config) error {
26+
if cfg == nil || cfg.Discovery == nil {
27+
return nil
28+
}
29+
lroServices := cfg.Discovery.LroServices()
30+
for _, svc := range model.Services {
31+
if _, ok := lroServices[svc.ID]; ok {
32+
continue
33+
}
34+
var svcMixin *api.Method
35+
for _, method := range svc.Methods {
36+
if method.OutputTypeID != cfg.Discovery.OperationID {
37+
continue
38+
}
39+
mixin, pathParams := lroFindPoller(method, model, cfg.Discovery)
40+
if mixin == nil {
41+
continue
42+
}
43+
method.DiscoveryLro = &api.DiscoveryLro{
44+
PollingPathParameters: pathParams,
45+
}
46+
if svcMixin != nil && mixin != svcMixin {
47+
return fmt.Errorf("mismatched LRO mixin, want=%v, got=%v", svcMixin, mixin)
48+
}
49+
svcMixin = mixin
50+
}
51+
if svcMixin == nil {
52+
continue
53+
}
54+
method := &api.Method{
55+
Name: "getOperation",
56+
ID: fmt.Sprintf("%s.getOperation", svc.ID),
57+
Documentation: svcMixin.Documentation,
58+
InputTypeID: svcMixin.InputTypeID,
59+
OutputTypeID: svcMixin.OutputTypeID,
60+
ReturnsEmpty: svcMixin.ReturnsEmpty,
61+
PathInfo: svcMixin.PathInfo,
62+
Pagination: svcMixin.Pagination,
63+
Routing: svcMixin.Routing,
64+
AutoPopulated: svcMixin.AutoPopulated,
65+
Service: svc,
66+
SourceService: svcMixin.Service,
67+
SourceServiceID: svcMixin.SourceServiceID,
68+
}
69+
svc.Methods = append(svc.Methods, method)
70+
model.State.MethodByID[method.ID] = method
71+
}
72+
return nil
73+
}
74+
75+
func lroFindPoller(method *api.Method, model *api.API, discoveryConfig *config.Discovery) (*api.Method, []string) {
76+
var flatPath []string
77+
for _, binding := range method.PathInfo.Bindings {
78+
flatPath = append(flatPath, binding.PathTemplate.FlatPath())
79+
}
80+
for _, candidate := range discoveryConfig.Pollers {
81+
for _, path := range flatPath {
82+
if !strings.HasPrefix(path, candidate.Prefix) {
83+
continue
84+
}
85+
if method, ok := model.State.MethodByID[candidate.MethodID]; ok {
86+
return method, candidate.PathParameters()
87+
}
88+
}
89+
}
90+
return nil, nil
91+
}

0 commit comments

Comments
 (0)