Skip to content

Commit 6fb12a4

Browse files
authored
fix(internal/surfer): identify singletons and preserve command leaf nodes (#5018)
* Identify singleton resources using a variable name heuristic and omit them from the command group hierarchy (not supported in declarative framework). * Ensure that flattened segments (such as locations or zones) are not skipped over when they contain commands. * Change root command group name to be snake case Fixes #4980
1 parent cdd77aa commit 6fb12a4

File tree

325 files changed

+310
-228
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

325 files changed

+310
-228
lines changed

internal/surfer/gcloud/command_group_builder.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,8 @@ func newCommandGroupBuilder(model *api.API, service *api.Service, config *provid
4949
}
5050

5151
func (b *commandGroupBuilder) buildRoot() *CommandGroup {
52-
// TODO (https://github.com/googleapis/librarian/issues/4945): Name can be an empty string here.
53-
// Need clear error handling rather than silent skipping
5452
return &CommandGroup{
55-
Name: b.model.Name,
53+
Name: provider.ResolveRootPackage(b.model),
5654
HelpText: fmt.Sprintf("Manage %s resources.", b.title),
5755
Groups: make(map[string]*CommandGroup),
5856
Commands: make(map[string]*Command),

internal/surfer/gcloud/command_tree_builder.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,16 @@ func (b *commandTreeBuilder) build() (*CommandGroupsByTrack, error) {
6363
// literal path segments of the method and walks the tree, creating missing
6464
// groups if they do not yet exist.
6565
func (b *commandTreeBuilder) insert(root *CommandGroup, groupBuilder *commandGroupBuilder, method *api.Method) error {
66-
segments := b.getLiteralSegments(method)
66+
if provider.IsSingletonResourceMethod(method, b.model) {
67+
return nil
68+
}
69+
70+
binding := provider.PrimaryBinding(method)
71+
if binding == nil {
72+
return nil
73+
}
74+
75+
segments := provider.GetLiteralSegments(binding.PathTemplate.Segments)
6776
if len(segments) == 0 {
6877
return nil
6978
}
@@ -73,7 +82,8 @@ func (b *commandTreeBuilder) insert(root *CommandGroup, groupBuilder *commandGro
7382
if b.isTerminatedSegment(seg) {
7483
return nil
7584
}
76-
if b.isFlattenedSegment(seg) {
85+
isLeaf := i == len(segments)-1
86+
if b.isFlattenedSegment(seg) && !isLeaf {
7787
continue
7888
}
7989

@@ -124,19 +134,9 @@ var flattenedSegments = map[string]bool{
124134
}
125135

126136
func (b *commandTreeBuilder) isFlattenedSegment(lit string) bool {
127-
// TODO (https://github.com/googleapis/librarian/issues/4980): these should not be flattened if
128-
// there are commands that should be generated for them.
129137
return flattenedSegments[lit]
130138
}
131139

132140
func (b *commandTreeBuilder) isTerminatedSegment(lit string) bool {
133141
return lit == "operations" && !provider.ShouldGenerateOperations(b.config)
134142
}
135-
136-
func (b *commandTreeBuilder) getLiteralSegments(method *api.Method) []string {
137-
if method.PathInfo == nil || len(method.PathInfo.Bindings) == 0 {
138-
return nil
139-
}
140-
// TODO(https://github.com/googleapis/librarian/issues/3415): handle multiple bindings
141-
return provider.GetLiteralSegments(method.PathInfo.Bindings[0].PathTemplate.Segments)
142-
}

internal/surfer/gcloud/command_tree_builder_test.go

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@
1515
package gcloud
1616

1717
import (
18+
"fmt"
1819
"path"
1920
"slices"
20-
"strings"
2121
"testing"
2222

2323
"github.com/google/go-cmp/cmp"
2424
"github.com/googleapis/librarian/internal/sidekick/api"
25+
"github.com/googleapis/librarian/internal/sidekick/parser/httprule"
2526
"github.com/googleapis/librarian/internal/surfer/gcloud/provider"
2627
)
2728

@@ -43,9 +44,9 @@ func TestCommandTreeBuilder_Build_EmptyDefaultHost(t *testing.T) {
4344

4445
func TestCommandTreeBuilder_Build_Structure(t *testing.T) {
4546
service := mockService("parallelstore.googleapis.com",
46-
mockMethod("CreateInstance", "v1/projects/locations/instances"),
47-
mockMethod("ListInstances", "v1/projects/locations/instances"),
48-
mockMethod("GetOperation", "v1/projects/locations/operations"),
47+
mockMethod("CreateInstance", "v1/{parent=projects/*/locations/*}/instances"),
48+
mockMethod("ListInstances", "v1/{parent=projects/*/locations/*}/instances"),
49+
mockMethod("GetOperation", "v1/{name=projects/*/locations/*/operations/*}"),
4950
)
5051
model := &api.API{
5152
Name: "parallelstore",
@@ -81,7 +82,7 @@ func TestCommandTreeBuilder_Build_Structure(t *testing.T) {
8182
}
8283

8384
func TestCommandTreeBuilder_Build_Operations_Disabled(t *testing.T) {
84-
service := mockService("parallelstore.googleapis.com", mockMethod("GetOperation", "v1/projects/locations/operations"))
85+
service := mockService("parallelstore.googleapis.com", mockMethod("GetOperation", "v1/{name=projects/*/locations/*/operations/*}"))
8586

8687
model := &api.API{
8788
Name: "parallelstore",
@@ -101,7 +102,7 @@ func TestCommandTreeBuilder_Build_Operations_Disabled(t *testing.T) {
101102
}
102103

103104
func TestCommandTreeBuilder_Build_Operations_Enabled(t *testing.T) {
104-
service := mockService("parallelstore.googleapis.com", mockMethod("GetOperation", "v1/projects/locations/operations"))
105+
service := mockService("parallelstore.googleapis.com", mockMethod("GetOperation", "v1/{name=projects/*/locations/*/operations/*}"))
105106

106107
model := &api.API{
107108
Name: "parallelstore",
@@ -125,8 +126,8 @@ func TestCommandTreeBuilder_Build_Operations_Enabled(t *testing.T) {
125126
}
126127

127128
func TestCommandTreeBuilder_Build_MultipleServices(t *testing.T) {
128-
serviceOne := mockService("ParallelstoreService", mockMethod("CreateInstance", "v1/projects/locations/instances"))
129-
serviceTwo := mockService("OtherParallelstoreService", mockMethod("CreateOtherInstance", "v1/projects/locations/otherInstances"))
129+
serviceOne := mockService("ParallelstoreService", mockMethod("CreateInstance", "v1/{parent=projects/*/locations/*}/instances"))
130+
serviceTwo := mockService("OtherParallelstoreService", mockMethod("CreateOtherInstance", "v1/{parent=projects/*/locations/*}/otherInstances"))
130131

131132
model := &api.API{
132133
Name: "parallelstore",
@@ -151,8 +152,8 @@ func TestCommandTreeBuilder_Build_MultipleServices(t *testing.T) {
151152
}
152153

153154
func TestCommandTreeBuilder_Build_MultipleReleaseTracks(t *testing.T) {
154-
serviceOne := mockService("ParallelstoreService", mockMethod("CreateInstance", "v1/projects/locations/instances"))
155-
serviceTwo := mockService("ParallelstoreService", mockMethod("CreateInstance", "v1alpha/projects/locations/instances"))
155+
serviceOne := mockService("ParallelstoreService", mockMethod("CreateInstance", "v1/{parent=projects/*/locations/*}/instances"))
156+
serviceTwo := mockService("ParallelstoreService", mockMethod("CreateInstance", "v1alpha/{parent=projects/*/locations/*}/instances"))
156157
serviceTwo.Package = "google.cloud.parallelstore.v1alpha"
157158

158159
model := &api.API{
@@ -203,23 +204,17 @@ func flattenTree(g *CommandGroup) []string {
203204
return paths
204205
}
205206

206-
func parseLiteralPath(path string) *api.PathTemplate {
207-
pt := api.NewPathTemplate()
208-
for _, part := range strings.Split(path, "/") {
209-
if part != "" {
210-
pt = pt.WithLiteral(part)
211-
}
212-
}
213-
return pt
214-
}
215-
216207
func mockMethod(name, path string) *api.Method {
208+
pt, err := httprule.ParseResourcePattern(path)
209+
if err != nil {
210+
panic(fmt.Sprintf("failed to parse path %q: %v", path, err))
211+
}
217212
return &api.Method{
218213
Name: name,
219214
PathInfo: &api.PathInfo{
220215
Bindings: []*api.PathBinding{
221216
{
222-
PathTemplate: parseLiteralPath(path),
217+
PathTemplate: pt,
223218
},
224219
},
225220
},

internal/surfer/gcloud/provider/method.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,18 @@ func IsStandardMethod(m *api.Method) bool {
124124
return IsGet(m) || IsList(m) || IsCreate(m) || IsUpdate(m) || IsDelete(m)
125125
}
126126

127+
// PrimaryBinding returns the primary path binding for the method, or nil if not available.
128+
func PrimaryBinding(m *api.Method) *api.PathBinding {
129+
if m.PathInfo == nil || len(m.PathInfo.Bindings) == 0 {
130+
return nil
131+
}
132+
return m.PathInfo.Bindings[0]
133+
}
134+
127135
// GetHTTPVerb returns the HTTP verb from the primary binding, or an empty string if not available.
128136
func GetHTTPVerb(m *api.Method) string {
129-
if m.PathInfo != nil && len(m.PathInfo.Bindings) > 0 {
130-
return m.PathInfo.Bindings[0].Verb
137+
if b := PrimaryBinding(m); b != nil {
138+
return b.Verb
131139
}
132140
return ""
133141
}
@@ -193,3 +201,14 @@ func FindResourceMessage(outputType *api.Message) *api.Message {
193201
}
194202
return nil
195203
}
204+
205+
// IsSingletonResourceMethod determines whether a resource is a singleton.
206+
// It checks if the resource associated with the method is a singleton.
207+
func IsSingletonResourceMethod(method *api.Method, model *api.API) bool {
208+
if method == nil {
209+
return false
210+
}
211+
212+
resource := GetResourceForMethod(method, model)
213+
return isSingletonResource(resource)
214+
}

internal/surfer/gcloud/provider/method_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,3 +305,128 @@ func TestIsStandardMethod(t *testing.T) {
305305
})
306306
}
307307
}
308+
309+
func TestIsSingletonResourceMethod(t *testing.T) {
310+
model := &api.API{
311+
ResourceDefinitions: []*api.Resource{
312+
{
313+
Type: "test.googleapis.com/Singleton",
314+
Patterns: []api.ResourcePattern{
315+
[]api.PathSegment{
316+
*api.NewPathSegment().WithLiteral("projects"),
317+
*api.NewPathSegment().WithVariable(api.NewPathVariable("project")),
318+
*api.NewPathSegment().WithLiteral("singletonConfig"),
319+
},
320+
},
321+
},
322+
{
323+
Type: "test.googleapis.com/AdjacentLiterals",
324+
Patterns: []api.ResourcePattern{
325+
[]api.PathSegment{
326+
*api.NewPathSegment().WithLiteral("projects"),
327+
*api.NewPathSegment().WithVariable(api.NewPathVariable("project")),
328+
*api.NewPathSegment().WithLiteral("literal1"),
329+
*api.NewPathSegment().WithLiteral("literal2"),
330+
*api.NewPathSegment().WithVariable(api.NewPathVariable("instance")),
331+
},
332+
},
333+
},
334+
{
335+
Type: "test.googleapis.com/Standard",
336+
Patterns: []api.ResourcePattern{
337+
[]api.PathSegment{
338+
*api.NewPathSegment().WithLiteral("projects"),
339+
*api.NewPathSegment().WithVariable(api.NewPathVariable("project")),
340+
*api.NewPathSegment().WithLiteral("instances"),
341+
*api.NewPathSegment().WithVariable(api.NewPathVariable("instance")),
342+
},
343+
},
344+
},
345+
},
346+
}
347+
348+
for _, test := range []struct {
349+
name string
350+
method *api.Method
351+
model *api.API
352+
want bool
353+
}{
354+
{
355+
name: "Nil Method",
356+
method: nil,
357+
model: &api.API{},
358+
want: false,
359+
},
360+
{
361+
name: "Fallback: No Resource Found, Path Ends in Variable",
362+
method: api.NewTestMethod("Test").WithPathTemplate(&api.PathTemplate{Segments: parseResourcePattern("projects/{project}/instances/{instance}")}),
363+
model: &api.API{},
364+
want: false,
365+
},
366+
{
367+
name: "Fallback: No Resource Found, Path Ends in Literal",
368+
method: api.NewTestMethod("Test").WithPathTemplate(&api.PathTemplate{Segments: parseResourcePattern("projects/{project}/singletonConfig")}),
369+
model: &api.API{},
370+
want: false, // Was true when fallback existed.
371+
},
372+
{
373+
name: "Resource Found: Singleton Pattern",
374+
method: func() *api.Method {
375+
m := api.NewTestMethod("GetSingleton")
376+
m.InputType = &api.Message{
377+
Fields: []*api.Field{{
378+
Name: "name",
379+
ResourceReference: &api.ResourceReference{
380+
Type: "test.googleapis.com/Singleton",
381+
},
382+
}},
383+
}
384+
return m
385+
}(),
386+
model: model,
387+
want: true,
388+
},
389+
{
390+
name: "Resource Found: Adjacent Literals Pattern",
391+
method: func() *api.Method {
392+
m := api.NewTestMethod("GetAdjacent")
393+
m.InputType = &api.Message{
394+
Fields: []*api.Field{{
395+
Name: "name",
396+
ResourceReference: &api.ResourceReference{
397+
Type: "test.googleapis.com/AdjacentLiterals",
398+
},
399+
}},
400+
}
401+
return m
402+
}(),
403+
model: model,
404+
want: true,
405+
},
406+
{
407+
name: "Resource Found: Standard Pattern",
408+
method: func() *api.Method {
409+
m := api.NewTestMethod("GetStandard")
410+
m.InputType = &api.Message{
411+
Fields: []*api.Field{{
412+
Name: "name",
413+
ResourceReference: &api.ResourceReference{
414+
Type: "test.googleapis.com/Standard",
415+
},
416+
}},
417+
}
418+
return m
419+
}(),
420+
model: model,
421+
want: false,
422+
},
423+
} {
424+
t.Run(test.name, func(t *testing.T) {
425+
t.Parallel()
426+
got := IsSingletonResourceMethod(test.method, test.model)
427+
if diff := cmp.Diff(test.want, got); diff != "" {
428+
t.Errorf("mismatch (-want +got):\n%s", diff)
429+
}
430+
})
431+
}
432+
}

internal/surfer/gcloud/provider/resource.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,35 @@ func GetResourceForMethod(method *api.Method, model *api.API) *api.Resource {
196196
return nil
197197
}
198198

199+
// isSingletonResource determines whether a resource is a singleton.
200+
// It checks if any of the resource's canonical patterns end in a literal segment
201+
// or contain two adjacent literal segments.
202+
func isSingletonResource(resource *api.Resource) bool {
203+
if resource == nil {
204+
return false
205+
}
206+
207+
for _, pattern := range resource.Patterns {
208+
if len(pattern) == 0 {
209+
continue
210+
}
211+
212+
// A pattern ending in a literal is a singleton (e.g., projects/{project}/singletonConfig).
213+
if pattern[len(pattern)-1].Literal != nil {
214+
return true
215+
}
216+
217+
// Adjacent literals anywhere in the pattern signify a singleton (e.g., .../literal1/literal2/...).
218+
for i := 0; i < len(pattern)-1; i++ {
219+
if pattern[i].Literal != nil && pattern[i+1].Literal != nil {
220+
return true
221+
}
222+
}
223+
}
224+
225+
return false
226+
}
227+
199228
// GetPluralResourceNameForMethod determines the plural name of a resource. It follows a clear
200229
// hierarchy of truth: first, the explicit `plural` field in the resource
201230
// definition, and second, inference from the resource pattern.

internal/surfer/gcloud/provider/service_info.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,12 @@ func GetServiceTitle(model *api.API, shortServiceName string) string {
5050
}
5151
return strcase.ToCamel(shortServiceName)
5252
}
53+
54+
// ResolveRootPackage extracts the service name from the package name (second to last element),
55+
// falling back to the provided fallback if there are not enough segments.
56+
func ResolveRootPackage(model *api.API) string {
57+
if parts := strings.Split(model.PackageName, "."); len(parts) >= 2 {
58+
return parts[len(parts)-2]
59+
}
60+
return model.Name
61+
}

internal/surfer/gcloud/provider/service_info_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,23 @@ func TestGetServiceTitle(t *testing.T) {
9393
})
9494
}
9595
}
96+
97+
func TestResolveRootPackage(t *testing.T) {
98+
for _, test := range []struct {
99+
name string
100+
model *api.API
101+
want string
102+
}{
103+
{"Standard package", &api.API{PackageName: "google.cloud.parallelstore.v1", Name: "fallback"}, "parallelstore"},
104+
{"Synthetic package", &api.API{PackageName: "resource_standard.v1", Name: "fallback"}, "resource_standard"},
105+
{"No dots", &api.API{PackageName: "parallelstore", Name: "fallback"}, "fallback"},
106+
{"Empty package", &api.API{PackageName: "", Name: "fallback"}, "fallback"},
107+
} {
108+
t.Run(test.name, func(t *testing.T) {
109+
got := ResolveRootPackage(test.model)
110+
if got != test.want {
111+
t.Errorf("ResolveRootPackage(%v) = %q, want %q", test.model, got, test.want)
112+
}
113+
})
114+
}
115+
}

0 commit comments

Comments
 (0)