Skip to content

Commit 9a2feb8

Browse files
feat(sidekick): Support QuickStart sample generation (#4257)
Add support for QuickStart sample generation in sidekick both at the service client level and the package level. It does so by selecting a significant method per client and a significant client per package. Generate Rust QuickStart samples for both the service client and the package.
1 parent fd8e45a commit 9a2feb8

11 files changed

Lines changed: 427 additions & 29 deletions

File tree

internal/sidekick/api/model.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ type API struct {
165165
State *APIState
166166
// ResourceDefinitions contains the data from the `google.api.resource_definition` annotation.
167167
ResourceDefinitions []*Resource
168+
// QuickstartService is the service that will be used to generate the quickstart sample
169+
// at the package level.
170+
QuickstartService *Service
168171
// Language specific annotations.
169172
Codec any
170173
}
@@ -230,6 +233,9 @@ type Service struct {
230233
// The model this service belongs to, mustache templates use this field to
231234
// navigate the data structure.
232235
Model *API
236+
// QuickstartMethod is the method that will be used to generate the quickstart sample
237+
// for this service.
238+
QuickstartMethod *Method
233239
// Language specific annotations.
234240
Codec any
235241
}

internal/sidekick/api/skip_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ func TestIncludeMethods(t *testing.T) {
372372
})
373373

374374
wantServices := []*Service{s1}
375-
if diff := cmp.Diff(wantServices, model.Services, cmpopts.IgnoreFields(Method{}, "Model"), cmpopts.IgnoreFields(Service{}, "Model")); diff != "" {
375+
if diff := cmp.Diff(wantServices, model.Services, cmpopts.IgnoreFields(Method{}, methodIgnoreFields...), cmpopts.IgnoreFields(Service{}, "Model", "QuickstartMethod")); diff != "" {
376376
t.Errorf("mismatch in services (-want, +got)\n:%s", diff)
377377
}
378378

internal/sidekick/api/xref.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,141 @@ func enrichSamples(model *API) {
111111
for _, m := range model.State.MethodByID {
112112
enrichMethodSamples(m)
113113
}
114+
115+
for _, s := range model.Services {
116+
s.QuickstartMethod = findQuickstartMethod(s)
117+
}
118+
model.QuickstartService = findQuickstartService(model)
119+
}
120+
121+
func findQuickstartMethod(s *Service) *Method {
122+
// Priority: List > Get > Create > Delete > Update
123+
priorities := []func(m *Method) bool{
124+
func(m *Method) bool { return m.IsAIPStandardList },
125+
func(m *Method) bool { return m.IsAIPStandardGet },
126+
func(m *Method) bool { return m.IsAIPStandardCreate },
127+
func(m *Method) bool { return m.IsAIPStandardDelete },
128+
func(m *Method) bool { return m.IsAIPStandardUpdate },
129+
// Fallback for when no standard AIP method is available: any method that is not streaming.
130+
func(m *Method) bool { return !m.ClientSideStreaming && !m.ServerSideStreaming },
131+
}
132+
133+
strippedServiceName := strings.TrimSuffix(s.Name, "Service")
134+
lowerStripped := strings.ToLower(strippedServiceName)
135+
136+
for _, isType := range priorities {
137+
var nonDeprecated []*Method
138+
var deprecated []*Method
139+
140+
for _, m := range s.Methods {
141+
if isType(m) {
142+
if m.Deprecated {
143+
deprecated = append(deprecated, m)
144+
} else {
145+
nonDeprecated = append(nonDeprecated, m)
146+
}
147+
}
148+
}
149+
150+
searchList := nonDeprecated
151+
if len(searchList) == 0 {
152+
searchList = deprecated
153+
}
154+
155+
if len(searchList) == 0 {
156+
continue
157+
}
158+
159+
if len(searchList) == 1 {
160+
return searchList[0]
161+
}
162+
163+
// Tie-breaking: Substring match on method name
164+
for _, m := range searchList {
165+
if strings.Contains(strings.ToLower(m.Name), lowerStripped) {
166+
return m
167+
}
168+
}
169+
170+
// Tie-breaking: Resource singular/plural match
171+
for _, m := range searchList {
172+
res := standardMethodOutputResource(m)
173+
if res != nil {
174+
if strings.ToLower(res.Singular) == lowerStripped || strings.ToLower(res.Plural) == lowerStripped {
175+
return m
176+
}
177+
}
178+
}
179+
180+
// Default to first candidate if no tie-breaker matches
181+
return searchList[0]
182+
}
183+
return nil
184+
}
185+
186+
func findQuickstartService(api *API) *Service {
187+
if len(api.Services) == 0 {
188+
return nil
189+
}
190+
191+
var nonDeprecated []*Service
192+
var deprecated []*Service
193+
194+
for _, s := range api.Services {
195+
if len(s.Methods) > 0 {
196+
if s.Deprecated {
197+
deprecated = append(deprecated, s)
198+
} else {
199+
nonDeprecated = append(nonDeprecated, s)
200+
}
201+
}
202+
}
203+
204+
searchList := nonDeprecated
205+
if len(searchList) == 0 {
206+
searchList = deprecated
207+
}
208+
209+
if len(searchList) == 0 {
210+
return api.Services[0]
211+
}
212+
213+
if len(searchList) == 1 {
214+
return searchList[0]
215+
}
216+
217+
// Prefer services with a QuickstartMethod that is an AIP standard method
218+
var servicesWithStandardQuickstart []*Service
219+
// Fallback to services with ANY QuickstartMethod
220+
var servicesWithAnyQuickstart []*Service
221+
222+
for _, s := range searchList {
223+
if s.QuickstartMethod != nil {
224+
servicesWithAnyQuickstart = append(servicesWithAnyQuickstart, s)
225+
if s.QuickstartMethod.IsAIPStandardList ||
226+
s.QuickstartMethod.IsAIPStandardGet ||
227+
s.QuickstartMethod.IsAIPStandardCreate ||
228+
s.QuickstartMethod.IsAIPStandardDelete ||
229+
s.QuickstartMethod.IsAIPStandardUpdate {
230+
servicesWithStandardQuickstart = append(servicesWithStandardQuickstart, s)
231+
}
232+
}
233+
}
234+
235+
if len(servicesWithStandardQuickstart) > 0 {
236+
searchList = servicesWithStandardQuickstart
237+
} else if len(servicesWithAnyQuickstart) > 0 {
238+
searchList = servicesWithAnyQuickstart
239+
}
240+
241+
lowerApiName := strings.ToLower(api.Name)
242+
for _, s := range searchList {
243+
if strings.Contains(strings.ToLower(s.Name), lowerApiName) {
244+
return s
245+
}
246+
}
247+
248+
return searchList[0]
114249
}
115250

116251
func enrichEnumSamples(e *Enum) {

internal/sidekick/api/xref_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1946,3 +1946,122 @@ func TestFindResourceIDField(t *testing.T) {
19461946
})
19471947
}
19481948
}
1949+
1950+
func TestFindQuickstartMethod(t *testing.T) {
1951+
fooMethod := &Method{Name: "FooMethod"}
1952+
listPolicies := &Method{Name: "ListAccessPolicies", IsAIPStandardList: true, OutputType: &Message{Resource: &Resource{Singular: "accesspolicy"}}}
1953+
getPolicy := &Method{Name: "GetAccessPolicy", IsAIPStandardGet: true}
1954+
createPolicy := &Method{Name: "CreateAccessPolicy", IsAIPStandardCreate: true}
1955+
deletePolicy := &Method{Name: "DeleteAccessPolicy", IsAIPStandardDelete: true}
1956+
updatePolicy := &Method{Name: "UpdateAccessPolicy", IsAIPStandardUpdate: true}
1957+
listOther := &Method{Name: "ListOtherThings", IsAIPStandardList: true, OutputType: &Message{Resource: &Resource{Singular: "otherthing"}}}
1958+
1959+
testCases := []struct {
1960+
name string
1961+
service *Service
1962+
want *Method
1963+
}{
1964+
{
1965+
name: "empty service",
1966+
service: &Service{Name: "AccessPolicyService", Methods: []*Method{}},
1967+
want: nil,
1968+
},
1969+
{
1970+
name: "fallback to simple method when no standard methods exist",
1971+
service: &Service{Name: "AccessPolicyService", Methods: []*Method{fooMethod}},
1972+
want: fooMethod,
1973+
},
1974+
{
1975+
name: "prefer non-deprecated simple method",
1976+
service: &Service{Name: "AccessPolicyService", Methods: []*Method{{Name: "DeprecatedList", Deprecated: true}, fooMethod}},
1977+
want: fooMethod,
1978+
},
1979+
{
1980+
name: "only get method",
1981+
service: &Service{Name: "AccessPolicyService", Methods: []*Method{getPolicy}},
1982+
want: getPolicy,
1983+
},
1984+
{
1985+
name: "prioritizes list over get",
1986+
service: &Service{Name: "AccessPolicyService", Methods: []*Method{getPolicy, listOther}},
1987+
want: listOther,
1988+
},
1989+
{
1990+
name: "prioritizes create over delete",
1991+
service: &Service{Name: "AccessPolicyService", Methods: []*Method{deletePolicy, createPolicy}},
1992+
want: createPolicy,
1993+
},
1994+
{
1995+
name: "prioritizes delete over update",
1996+
service: &Service{Name: "AccessPolicyService", Methods: []*Method{updatePolicy, deletePolicy}},
1997+
want: deletePolicy,
1998+
},
1999+
{
2000+
name: "tie-breaking on name matching (ListAccessPolicies vs ListOtherThings for AccessPolicyService)",
2001+
service: &Service{Name: "AccessPolicyService", Methods: []*Method{listOther, listPolicies}},
2002+
want: listPolicies,
2003+
},
2004+
{
2005+
name: "tie-breaking fallback to resource singular/plural",
2006+
service: &Service{Name: "AccessPolicyService", Methods: []*Method{listOther, {Name: "ListPolicies", IsAIPStandardList: true, OutputType: &Message{Resource: &Resource{Singular: "accesspolicy"}}}}},
2007+
want: &Method{Name: "ListPolicies", IsAIPStandardList: true, OutputType: &Message{Resource: &Resource{Singular: "accesspolicy"}}}, // matches singular 'accesspolicy'
2008+
},
2009+
}
2010+
2011+
for _, tc := range testCases {
2012+
t.Run(tc.name, func(t *testing.T) {
2013+
got := findQuickstartMethod(tc.service)
2014+
if diff := cmp.Diff(tc.want, got, cmpopts.IgnoreFields(Method{}, "Service", "Model")); diff != "" {
2015+
t.Errorf("findQuickstartMethod() mismatch (-want +got):\n%s", diff)
2016+
}
2017+
})
2018+
}
2019+
}
2020+
2021+
func TestFindQuickstartService(t *testing.T) {
2022+
fooMethod := &Method{Name: "FooMethod"}
2023+
serviceA := &Service{Name: "ServiceA", Methods: []*Method{fooMethod}}
2024+
serviceB := &Service{Name: "SecretManagerService", Methods: []*Method{fooMethod}}
2025+
deprecatedService := &Service{Name: "SecretManagerService", Deprecated: true, Methods: []*Method{fooMethod}}
2026+
2027+
testCases := []struct {
2028+
name string
2029+
api *API
2030+
want *Service
2031+
}{
2032+
{
2033+
name: "no services",
2034+
api: &API{Name: "secretmanager", Services: nil},
2035+
want: nil,
2036+
},
2037+
{
2038+
name: "one service",
2039+
api: &API{Name: "secretmanager", Services: []*Service{serviceA}},
2040+
want: serviceA,
2041+
},
2042+
{
2043+
name: "match service name to api name",
2044+
api: &API{Name: "secretmanager", Services: []*Service{serviceA, serviceB}},
2045+
want: serviceB,
2046+
},
2047+
{
2048+
name: "no match defaults to first",
2049+
api: &API{Name: "otherapi", Services: []*Service{serviceA, serviceB}},
2050+
want: serviceA,
2051+
},
2052+
{
2053+
name: "prefer non-deprecated service",
2054+
api: &API{Name: "secretmanager", Services: []*Service{deprecatedService, serviceA}},
2055+
want: serviceA,
2056+
},
2057+
}
2058+
2059+
for _, tc := range testCases {
2060+
t.Run(tc.name, func(t *testing.T) {
2061+
got := findQuickstartService(tc.api)
2062+
if diff := cmp.Diff(tc.want, got); diff != "" {
2063+
t.Errorf("findQuickstartService() mismatch (-want +got):\n%s", diff)
2064+
}
2065+
})
2066+
}
2067+
}

internal/sidekick/rust/annotate.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ type modelAnnotations struct {
8080
DetailedTracingAttributes bool
8181
// If true, the generated builders's visibility should be restricted to the crate.
8282
InternalBuilders bool
83+
// The service to use for the package-level quickstart sample.
84+
// Rust generation may decide not to generate some services,
85+
// e.g. if the methods have no bindings. On occasion the service
86+
// selected at the model level will be skipped for Rust generation
87+
// so we need to choose a different one.
88+
QuickstartService *api.Service
8389
}
8490

8591
// IsWktCrate returns true when bootstrapping the well-known types crate the templates add some
@@ -681,6 +687,17 @@ func annotateModel(model *api.API, codec *codec) (*modelAnnotations, error) {
681687
}
682688
return defaultHost[:idx]
683689
}()
690+
691+
var quickstartService *api.Service
692+
if model.QuickstartService != nil {
693+
if slices.ContainsFunc(servicesSubset, func(s *api.Service) bool { return s == model.QuickstartService }) {
694+
quickstartService = model.QuickstartService
695+
}
696+
}
697+
if quickstartService == nil && len(servicesSubset) > 0 {
698+
quickstartService = servicesSubset[0]
699+
}
700+
684701
ann := &modelAnnotations{
685702
PackageName: codec.packageName(model),
686703
PackageNamespace: codec.rootModuleName(model),
@@ -709,6 +726,7 @@ func annotateModel(model *api.API, codec *codec) (*modelAnnotations, error) {
709726
GenerateRpcSamples: codec.generateRpcSamples,
710727
DetailedTracingAttributes: codec.detailedTracingAttributes,
711728
InternalBuilders: codec.internalBuilders,
729+
QuickstartService: quickstartService,
712730
}
713731

714732
codec.addFeatureAnnotations(model, ann)

internal/sidekick/rust/annotate_model_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,66 @@ func TestInternalBuildersAnnotation(t *testing.T) {
185185
}
186186
}
187187

188+
func TestQuickstartServiceAnnotation(t *testing.T) {
189+
t.Run("survives filtering", func(t *testing.T) {
190+
model := newTestAnnotateModelAPI()
191+
// model.Services[0] is Service0, model.Services[1] is Service1
192+
model.QuickstartService = model.Services[1]
193+
194+
codec := newTestCodec(t, libconfig.SpecProtobuf, "", nil)
195+
got, err := annotateModel(model, codec)
196+
if err != nil {
197+
t.Fatal(err)
198+
}
199+
200+
if got.QuickstartService == nil {
201+
t.Fatal("QuickstartService should not be nil")
202+
}
203+
if got.QuickstartService != model.Services[1] {
204+
t.Errorf("expected QuickstartService to be Service1, got %v", got.QuickstartService.Name)
205+
}
206+
})
207+
208+
t.Run("filtered out fallback", func(t *testing.T) {
209+
model := newTestAnnotateModelAPI()
210+
211+
// Create a service that has no methods with bindings, so it will be filtered out.
212+
filteredService := &api.Service{
213+
Name: "FilteredService",
214+
ID: "..FilteredService",
215+
Package: "test.v1",
216+
Methods: []*api.Method{
217+
{
218+
Name: "noBindings",
219+
ID: "..FilteredService.noBindings",
220+
},
221+
},
222+
}
223+
model.Services = append(model.Services, filteredService)
224+
for _, s := range model.Services {
225+
s.Model = model
226+
}
227+
api.CrossReference(model)
228+
229+
// Set the filtered service as the global quickstart.
230+
model.QuickstartService = filteredService
231+
232+
codec := newTestCodec(t, libconfig.SpecProtobuf, "", nil)
233+
got, err := annotateModel(model, codec)
234+
if err != nil {
235+
t.Fatal(err)
236+
}
237+
238+
if got.QuickstartService == nil {
239+
t.Fatal("QuickstartService should not be nil")
240+
}
241+
// It should have fallen back to the first non-filtered service (Service0).
242+
if got.QuickstartService != model.Services[0] {
243+
t.Errorf("expected QuickstartService to fall back to Service0, got %v", got.QuickstartService.Name)
244+
}
245+
})
246+
}
247+
188248
func newTestAnnotateModelAPI() *api.API {
189249
service0 := &api.Service{
190250
Name: "Service0",

0 commit comments

Comments
 (0)