Skip to content

Commit 2723d08

Browse files
authored
feat(internal/surfer): add robust Method Identification (#3571)
This PR implements robust AIP-compliant method identification logic and refactors the associated utility functions and tests to align with project conventions and ensure reliable gcloud surface generation. ### Key Changes 1. **Robust Identification Logic (`utils/method.go`):** - Renamed `GetVerb` to `GetCommandName` to more accurately reflect its purpose (providing the gcloud command name used for file naming and CLI invocation). - Updated `IsCreate`, `IsGet`, `IsList`, `IsUpdate`, and `IsDelete` to accept `*api.Method` and use a combination of AIP standard prefixes and HTTP verbs for more reliable detection. - Implemented custom verb extraction for non-standard methods as per AIP-136 (e.g., extracting `exportData` from the HTTP path). - Ensured all returned command names use `snake_case` to maintain consistency with `gcloud` file naming conventions. 2. **Model Safety (`internal/sidekick/api/model.go`):** - Added nil checks for `m.OutputType` in `AIPStandardGetInfo` to prevent panics during model analysis, particularly in testing environments with partial mocks. 3. **Codebase Integration:** - Updated all call sites in `builder.go` and `generate.go` to use the new `GetCommandName` signature and robust predicates. ### Impact - **Reliability:** The generator can now correctly identify custom methods and standard methods even when naming conventions are slightly varied, as long as the HTTP annotations are present. - **Stability:** Fixed a latent panic in the core API model logic. - **Maintainability:** Cleaned up "mutation soup" in method identification and brought test code up to project standards. Fixes #3362
1 parent 64a8571 commit 2723d08

File tree

6 files changed

+227
-77
lines changed

6 files changed

+227
-77
lines changed

internal/sidekick/api/model.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ type AIPStandardGetInfo struct {
391391
// a get operation as defined by AIP-131, if the method is such an operation.
392392
func (m *Method) AIPStandardGetInfo() *AIPStandardGetInfo {
393393
// A get operation is always a simple operation that returns a resource.
394-
if !m.IsSimple() || m.InputType == nil || m.ReturnsEmpty || m.OutputType.Resource == nil {
394+
if !m.IsSimple() || m.InputType == nil || m.ReturnsEmpty || m.OutputType == nil || m.OutputType.Resource == nil {
395395
return nil
396396
}
397397

internal/surfer/gcloud/builder.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ func shouldSkipParam(field *api.Field, method *api.Method) bool {
128128
}
129129

130130
// For Update commands, fields marked as IMMUTABLE cannot be changed and should be hidden.
131-
if utils.IsUpdate(method.Name) && slices.Contains(field.Behavior, api.FIELD_BEHAVIOR_IMMUTABLE) {
131+
if utils.IsUpdate(method) && slices.Contains(field.Behavior, api.FIELD_BEHAVIOR_IMMUTABLE) {
132132
return true
133133
}
134134

@@ -226,7 +226,7 @@ func newParam(field *api.Field, apiField string, overrides *Config, model *api.A
226226
}
227227

228228
// For Update commands, maps and repeated fields are often clearable.
229-
if utils.IsUpdate(method.Name) && param.Repeated {
229+
if utils.IsUpdate(method) && param.Repeated {
230230
param.Clearable = true
231231
}
232232

@@ -267,7 +267,7 @@ func newPrimaryResourceParam(field *api.Field, method *api.Method, model *api.AP
267267

268268
// We generate a helpful help text based on whether the command is a `Create` command or not.
269269
helpText := fmt.Sprintf("The %s to create.", resourceName)
270-
if !strings.HasPrefix(method.Name, "Create") {
270+
if !utils.IsCreate(method) {
271271
helpText = fmt.Sprintf("The %s to operate on.", resourceName)
272272
}
273273

@@ -286,7 +286,7 @@ func newPrimaryResourceParam(field *api.Field, method *api.Method, model *api.AP
286286
},
287287
}
288288

289-
if utils.IsCreate(method.Name) {
289+
if utils.IsCreate(method) {
290290
param.RequestIDField = strcase.ToLowerCamel(field.Name)
291291
}
292292

internal/surfer/gcloud/generate.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,11 @@ func generateResourceCommands(collectionID string, methods []*api.Method, baseDi
162162

163163
// We iterate through each method associated with this resource.
164164
for _, method := range methods {
165-
// We map the API method name to a standard gcloud command verb.
165+
// We map the API method to a standard gcloud command name.
166166
// Example: `CreateInstance` -> "create"
167-
verb, err := utils.GetVerb(method.Name)
167+
verb, err := utils.GetCommandName(method)
168168
if err != nil {
169-
// Continue to the next method if we can't determine a verb,
169+
// Continue to the next method if we can't determine a command name,
170170
// logging the issue might be useful here in the future.
171171
continue
172172
}

internal/surfer/gcloud/utils/method.go

Lines changed: 84 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,53 +18,110 @@ import (
1818
"fmt"
1919
"strings"
2020

21+
"github.com/googleapis/librarian/internal/sidekick/api"
2122
"github.com/iancoleman/strcase"
2223
)
2324

24-
// GetVerb maps an API method name to a standard gcloud command verb.
25-
func GetVerb(methodName string) (string, error) {
26-
if methodName == "" {
27-
return "", fmt.Errorf("method name cannot be empty")
25+
// GetCommandName maps an API method to a standard gcloud command name (in snake_case).
26+
// This name is typically used for the command's file name.
27+
func GetCommandName(method *api.Method) (string, error) {
28+
if method == nil {
29+
return "", fmt.Errorf("method cannot be nil")
2830
}
2931
switch {
30-
case IsGet(methodName):
32+
case IsGet(method):
3133
return "describe", nil
32-
case IsList(methodName):
34+
case IsList(method):
3335
return "list", nil
34-
case IsCreate(methodName):
36+
case IsCreate(method):
3537
return "create", nil
36-
case IsUpdate(methodName):
38+
case IsUpdate(method):
3739
return "update", nil
38-
case IsDelete(methodName):
40+
case IsDelete(method):
3941
return "delete", nil
4042
default:
41-
// For non-standard methods, we just use the snake_case version of the method name.
42-
return strcase.ToSnake(methodName), nil
43+
// For custom methods (AIP-136), we try to extract the custom verb from the HTTP path.
44+
// The custom verb is the part after the colon (e.g., .../instances/*:exportData).
45+
if method.PathInfo != nil && len(method.PathInfo.Bindings) > 0 {
46+
binding := method.PathInfo.Bindings[0]
47+
if binding.PathTemplate != nil && binding.PathTemplate.Verb != nil {
48+
return strcase.ToSnake(*binding.PathTemplate.Verb), nil
49+
}
50+
}
51+
// Fallback: use the method name converted to snake_case.
52+
return strcase.ToSnake(method.Name), nil
4353
}
4454
}
4555

46-
// IsCreate determines if the method is a Create method.
47-
// TODO(https://github.com/googleapis/librarian/issues/3362): implement a robust AIP-compliant method idetification.
48-
func IsCreate(methodName string) bool {
49-
return strings.HasPrefix(methodName, "Create")
56+
// IsCreate determines if the method is a standard Create method (AIP-133).
57+
func IsCreate(m *api.Method) bool {
58+
if !strings.HasPrefix(m.Name, "Create") {
59+
return false
60+
}
61+
if verb := getHTTPVerb(m); verb != "" {
62+
return verb == "POST"
63+
}
64+
return true
5065
}
5166

52-
// IsGet determines if the method is a Get method.
53-
func IsGet(methodName string) bool {
54-
return strings.HasPrefix(methodName, "Get")
67+
// IsGet determines if the method is a standard Get method (AIP-131).
68+
func IsGet(m *api.Method) bool {
69+
// Use sidekick's robust AIP check if available.
70+
if m.AIPStandardGetInfo() != nil {
71+
return true
72+
}
73+
// Fallback heuristic
74+
if !strings.HasPrefix(m.Name, "Get") {
75+
return false
76+
}
77+
if verb := getHTTPVerb(m); verb != "" {
78+
return verb == "GET"
79+
}
80+
return true
5581
}
5682

57-
// IsList determines if the method is a List method.
58-
func IsList(methodName string) bool {
59-
return strings.HasPrefix(methodName, "List")
83+
// IsList determines if the method is a standard List method (AIP-132).
84+
func IsList(m *api.Method) bool {
85+
if !strings.HasPrefix(m.Name, "List") {
86+
return false
87+
}
88+
if verb := getHTTPVerb(m); verb != "" {
89+
return verb == "GET"
90+
}
91+
return true
6092
}
6193

62-
// IsUpdate determines if the method is a Update method.
63-
func IsUpdate(methodName string) bool {
64-
return strings.HasPrefix(methodName, "Update")
94+
// IsUpdate determines if the method is a standard Update method (AIP-134).
95+
func IsUpdate(m *api.Method) bool {
96+
if !strings.HasPrefix(m.Name, "Update") {
97+
return false
98+
}
99+
if verb := getHTTPVerb(m); verb != "" {
100+
return verb == "PATCH" || verb == "PUT"
101+
}
102+
return true
65103
}
66104

67-
// IsDelete determines if the method is a Delete method.
68-
func IsDelete(methodName string) bool {
69-
return strings.HasPrefix(methodName, "Delete")
105+
// IsDelete determines if the method is a standard Delete method (AIP-135).
106+
func IsDelete(m *api.Method) bool {
107+
// Use sidekick's robust AIP check if available.
108+
if m.AIPStandardDeleteInfo() != nil {
109+
return true
110+
}
111+
// Fallback heuristic
112+
if !strings.HasPrefix(m.Name, "Delete") {
113+
return false
114+
}
115+
if verb := getHTTPVerb(m); verb != "" {
116+
return verb == "DELETE"
117+
}
118+
return true
119+
}
120+
121+
// getHTTPVerb returns the HTTP verb from the primary binding, or an empty string if not available.
122+
func getHTTPVerb(m *api.Method) string {
123+
if m.PathInfo != nil && len(m.PathInfo.Bindings) > 0 {
124+
return m.PathInfo.Bindings[0].Verb
125+
}
126+
return ""
70127
}

0 commit comments

Comments
 (0)