diff --git a/internal/gengapic/BUILD.bazel b/internal/gengapic/BUILD.bazel index 3f27a780b6..b0f10b4e8c 100644 --- a/internal/gengapic/BUILD.bazel +++ b/internal/gengapic/BUILD.bazel @@ -8,6 +8,7 @@ go_library( "custom_operation.go", "doc_file.go", "example.go", + "feature.go", "generator.go", "gengapic.go", "gengrpc.go", @@ -69,6 +70,7 @@ go_test( "custom_operation_test.go", "doc_file_test.go", "example_test.go", + "feature_test.go", "generator_test.go", "gengapic_test.go", "genrest_test.go", diff --git a/internal/gengapic/client_init_test.go b/internal/gengapic/client_init_test.go index 1c2993e9b6..e370139415 100644 --- a/internal/gengapic/client_init_test.go +++ b/internal/gengapic/client_init_test.go @@ -134,7 +134,12 @@ func TestClientOpt(t *testing.T) { {Name: "google.longrunning.Operations"}, }, }, - gRPCServiceConfig: grpcConf}, + gRPCServiceConfig: grpcConf, + // Showcase would enable MTLS if we went through legacy enablements, so add it explicitly here. + featureEnablement: map[featureID]struct{}{ + MTLSHardBoundTokensFeature: struct{}{}, + }, + }, } serv := &descriptorpb.ServiceDescriptorProto{ diff --git a/internal/gengapic/feature.go b/internal/gengapic/feature.go new file mode 100644 index 0000000000..59a3d765db --- /dev/null +++ b/internal/gengapic/feature.go @@ -0,0 +1,130 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gengapic + +import "google.golang.org/protobuf/types/pluginpb" + +// We use a custom type for feature ID to clarify that features are setup explicitly. +type featureID string + +// featureInfo contains basic information about features, and provides an extensible mechanism +// for adding more infomation down the line (maturity level, warning about stale experiments, etc) +type featureInfo struct { + // Short summary of feature. + Description string + + // Tracking ID for more information. Github issue, internal b/ issue, etc. + TrackingID string +} + +// Define feature ID strings here. More details about features are kept in the featureRegistry map. +const ( + WrapperTypesForPageSizeFeature featureID = "wrapper_types_for_page_size" + OrderedRoutingHeadersFeature featureID = "ordered_routing_headers" + MTLSHardBoundTokensFeature featureID = "mtls_hard_bound_tokens" +) + +// featureRegistry contains the registry of defined features. +// Introducing a new capability to the generator generally starts here, as features +// must be registered to be enabled. This should not be modified at runtime. Those +// who attempt to do so will be given a stern talking to. +var featureRegistry = map[featureID]*featureInfo{ + MTLSHardBoundTokensFeature: { + Description: "support MTLS hard bound tokens", + TrackingID: "b/327916505", + }, + OrderedRoutingHeadersFeature: { + Description: "Specify that routing headers are emitted in a deterministic fashion. Primarily used for firestore.", + }, + WrapperTypesForPageSizeFeature: { + Description: "Allow List RPCs to generator with support for protobuf wrapper types (e.g. Int32Value, etc).", + TrackingID: "b/352331075", + }, +} + +// legacyFeatureEnablementByPackage is temporary bridge functionality. Features should be enabled via protoc flags, but +// to bootstrap without breaking generation we keep the legacy definitions enabled here until we can move +// configuration upstream into tools like librarian/bazel/etc as needed. +var legacyFeatureEnablementByPackage = map[featureID][]string{ + OrderedRoutingHeadersFeature: []string{ + "google.firestore.v1", + "google.firestore.admin.v1", + }, + WrapperTypesForPageSizeFeature: []string{ + "google.cloud.bigquery.v2", + }, +} + +// similar to legacyFeatureEnablementByPackage, this is legacy feature enablement using the "name" field from the API +// service config. +var legacyFeatureEnablementByAPIName = map[featureID][]string{ + MTLSHardBoundTokensFeature: []string{ + "bigquery.googleapis.com", + "cloudasset.googleapis.com", + "clouderrorreporting.googleapis.com", + "cloudkms.googleapis.com", + "cloudresourcemanager.googleapis.com", + "cloudtasks.googleapis.com", + "cloudtrace.googleapis.com", + "dataflow.googleapis.com", + "datastore.googleapis.com", + "essentialcontacts.googleapis.com", + "firestore.googleapis.com", + "iam.googleapis.com", + "iamcredentials.googleapis.com", + "logging.googleapis.com", + "monitoring.googleapis.com", + "orgpolicy.googleapis.com", + "pubsub.googleapis.com", + "recommender.googleapis.com", + "secretmanager.googleapis.com", + "showcase.googleapis.com", + }, +} + +// This function consolidates legacy processing of feature enablements. +// Like the associated allowlists, it should go away once librarian and bazel can pass feature enablements directly. +func processLegacyEnablements(cfg *generatorConfig, req *pluginpb.CodeGeneratorRequest) { + // Use the first proto file in the FileDescriptorSet to handle legacy enablement by package name. + if len(req.GetProtoFile()) > 0 { + probePackage := req.GetProtoFile()[0].GetPackage() + for f, packages := range legacyFeatureEnablementByPackage { + for _, v := range packages { + if probePackage == v { // matched + if cfg.featureEnablement == nil { + cfg.featureEnablement = make(map[featureID]struct{}) + } + cfg.featureEnablement[f] = struct{}{} + break + } + } + } + } + // Now, process legacy feature enablements based on API name. + if cfg.APIServiceConfig != nil { + probeName := cfg.APIServiceConfig.GetName() + for f, apis := range legacyFeatureEnablementByAPIName { + for _, v := range apis { + if probeName == v { // matched + if cfg.featureEnablement == nil { + cfg.featureEnablement = make(map[featureID]struct{}) + } + cfg.featureEnablement[f] = struct{}{} + break + } + } + } + } +} diff --git a/internal/gengapic/feature_test.go b/internal/gengapic/feature_test.go new file mode 100644 index 0000000000..159a6b4ff9 --- /dev/null +++ b/internal/gengapic/feature_test.go @@ -0,0 +1,102 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gengapic + +import ( + "testing" + + "google.golang.org/genproto/googleapis/api/serviceconfig" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/pluginpb" +) + +func TestProcessLegacyEnablements(t *testing.T) { + for _, tc := range []struct { + desc string + protoPkg string + apiName string + wantFeaturesEnabled []featureID + wantFeatureDisabled []featureID + }{ + { + desc: "default", + wantFeatureDisabled: []featureID{ + MTLSHardBoundTokensFeature, + OrderedRoutingHeadersFeature, + WrapperTypesForPageSizeFeature, + }, + }, + { + desc: "bigquery package", + protoPkg: "google.cloud.bigquery.v2", + wantFeaturesEnabled: []featureID{WrapperTypesForPageSizeFeature}, + }, + { + desc: "bigquery package only", + protoPkg: "google.cloud.bigquery.v2", + wantFeaturesEnabled: []featureID{WrapperTypesForPageSizeFeature}, + wantFeatureDisabled: []featureID{OrderedRoutingHeadersFeature, MTLSHardBoundTokensFeature}, + }, + { + desc: "firestore admin package only", + protoPkg: "google.firestore.admin.v1", + wantFeaturesEnabled: []featureID{OrderedRoutingHeadersFeature}, + wantFeatureDisabled: []featureID{WrapperTypesForPageSizeFeature, MTLSHardBoundTokensFeature}, + }, + { + desc: "cloud kms api name", + apiName: "cloudkms.googleapis.com", + wantFeaturesEnabled: []featureID{MTLSHardBoundTokensFeature}, + wantFeatureDisabled: []featureID{WrapperTypesForPageSizeFeature, OrderedRoutingHeadersFeature}, + }, + } { + t.Run(tc.desc, func(t *testing.T) { + req := &pluginpb.CodeGeneratorRequest{ + Parameter: proto.String("go-gapic-package=foo/bar/baz;baz"), + ProtoFile: []*descriptorpb.FileDescriptorProto{ + { + Package: proto.String("foo"), + }, + }, + } + if tc.protoPkg != "" { + req.ProtoFile[0].Package = proto.String(tc.protoPkg) + } + cfg, err := configFromRequest(req.Parameter) + if err != nil { + t.Fatalf("configFromRequest err: %v", err) + } + if tc.apiName != "" { + cfg.APIServiceConfig = &serviceconfig.Service{ + Name: tc.apiName, + } + } + g := &generator{cfg: cfg} + processLegacyEnablements(cfg, req) + for _, f := range tc.wantFeaturesEnabled { + if !g.featureEnabled(f) { + t.Errorf("expected feature %q enabled, was not", f) + } + } + for _, f := range tc.wantFeatureDisabled { + if g.featureEnabled(f) { + t.Errorf("expected feature %q to be disabled, was enabled", f) + } + } + }) + + } +} diff --git a/internal/gengapic/generator.go b/internal/gengapic/generator.go index 8e3689b672..5addc9718f 100644 --- a/internal/gengapic/generator.go +++ b/internal/gengapic/generator.go @@ -31,39 +31,6 @@ import ( "google.golang.org/protobuf/types/pluginpb" ) -// keyed by proto package name, e.g. "google.cloud.foo.v1". -var enableWrapperTypesForPageSize = map[string]bool{ - "google.cloud.bigquery.v2": true, -} - -var enableOrderedRoutingHeaders = map[string]bool{ - "google.firestore.v1": true, - "google.firestore.admin.v1": true, -} - -var enableMtlsHardBoundTokens = map[string]bool{ - "bigquery.googleapis.com": true, - "cloudasset.googleapis.com": true, - "clouderrorreporting.googleapis.com": true, - "cloudkms.googleapis.com": true, - "cloudresourcemanager.googleapis.com": true, - "cloudtasks.googleapis.com": true, - "cloudtrace.googleapis.com": true, - "dataflow.googleapis.com": true, - "datastore.googleapis.com": true, - "essentialcontacts.googleapis.com": true, - "firestore.googleapis.com": true, - "iam.googleapis.com": true, - "iamcredentials.googleapis.com": true, - "logging.googleapis.com": true, - "monitoring.googleapis.com": true, - "orgpolicy.googleapis.com": true, - "pubsub.googleapis.com": true, - "recommender.googleapis.com": true, - "secretmanager.googleapis.com": true, - "showcase.googleapis.com": true, -} - type generator struct { pt printer.P @@ -131,6 +98,11 @@ func newGenerator(req *pluginpb.CodeGeneratorRequest) (*generator, error) { if err != nil { return nil, err } + // Handle legacy enablement via hardcoded allowlists. + // This logic should be removed when legacy enablement is no longer needed. (b/264668184) + processLegacyEnablements(cfg, req) + + // attach config to generator. g.cfg = cfg files := req.GetProtoFile() @@ -171,6 +143,17 @@ func newGenerator(req *pluginpb.CodeGeneratorRequest) (*generator, error) { return g, nil } +// featureEnabled is a simple boolean checker for probing if a given feature has been enabled. +func (g *generator) featureEnabled(f featureID) bool { + if g.cfg == nil { + return false + } + if _, ok := g.cfg.featureEnablement[f]; ok { + return true + } + return false +} + // printf formatted-prints to sb, using the print syntax from fmt package. // // It automatically keeps track of indentation caused by curly-braces. diff --git a/internal/gengapic/gengapic.go b/internal/gengapic/gengapic.go index fc36fd4407..36fe3ee5fe 100644 --- a/internal/gengapic/gengapic.go +++ b/internal/gengapic/gengapic.go @@ -456,14 +456,7 @@ func (g *generator) insertDynamicRequestHeaders(m *descriptorpb.MethodDescriptor g.printf(" routingHeadersMap[%q] = %s", headerName, regexHelper) g.printf("}") } - var orderedHeaders bool - for p, ok := range enableOrderedRoutingHeaders { - if g.descInfo.ParentFile[m].GetPackage() == p && ok { - orderedHeaders = true - break - } - } - if orderedHeaders { + if g.featureEnabled(OrderedRoutingHeadersFeature) { for i := range headers { headerName := headers[i][2] g.printf("if headerValue, ok := routingHeadersMap[%q]; ok {", headerName) diff --git a/internal/gengapic/gengapic_test.go b/internal/gengapic/gengapic_test.go index 0bc148a106..607dcf0b5e 100644 --- a/internal/gengapic/gengapic_test.go +++ b/internal/gengapic/gengapic_test.go @@ -1425,6 +1425,7 @@ func TestInsertDynamicRequestHeaders_Ordering(t *testing.T) { Type: make(map[string]pbinfo.ProtoType), ParentFile: make(map[protoreflect.ProtoMessage]*descriptorpb.FileDescriptorProto), }, + cfg: &generatorConfig{}, } m := &descriptorpb.MethodDescriptorProto{ @@ -1467,6 +1468,10 @@ func TestInsertDynamicRequestHeaders_Ordering(t *testing.T) { for _, tc := range tests { t.Run(tc.pkgName, func(t *testing.T) { g.reset() + g.cfg.featureEnablement = make(map[featureID]struct{}) + if tc.wantOrdered { + g.cfg.featureEnablement[OrderedRoutingHeadersFeature] = struct{}{} + } file := &descriptorpb.FileDescriptorProto{ Package: proto.String(tc.pkgName), diff --git a/internal/gengapic/gengrpc.go b/internal/gengapic/gengrpc.go index f09fbcad78..d0c35da4d7 100644 --- a/internal/gengapic/gengrpc.go +++ b/internal/gengapic/gengrpc.go @@ -186,7 +186,7 @@ func (g *generator) grpcClientOptions(serv *descriptorpb.ServiceDescriptorProto, p(" internaloption.WithDefaultAudience(%q),", generateDefaultAudience(host)) p(" internaloption.WithDefaultScopes(DefaultAuthScopes()...),") p(" internaloption.EnableJwtWithScope(),") - if _, ok := enableMtlsHardBoundTokens[g.cfg.APIServiceConfig.GetName()]; ok { + if g.featureEnabled(MTLSHardBoundTokensFeature) { p("internaloption.AllowHardBoundTokens(\"MTLS_S2A\"),") } p(" internaloption.EnableNewAuthLibrary(),") diff --git a/internal/gengapic/options.go b/internal/gengapic/options.go index 915f470959..9de14f5c57 100644 --- a/internal/gengapic/options.go +++ b/internal/gengapic/options.go @@ -73,7 +73,8 @@ var SupportedValueArgs map[string]func(string) configOption = map[string]func(st // // Recommendation: avoid adding more of these as they complicate processing. var SupportedPrefixArgs map[string]func(string) configOption = map[string]func(string) configOption{ - "M": withPackageOverride, + "M": withPackageOverride, + "F_": withFeature, } // Configuration needed to drive the operation of the plugin. @@ -121,6 +122,9 @@ type generatorConfig struct { // Package naming overrides, keyed by filepath. pkgOverrides map[string]string + + // Keep track of what conditional behaviors/experiments are enabled. + featureEnablement map[featureID]struct{} } // Signature for configuration arguments. @@ -385,6 +389,24 @@ func withPackageOverride(s string) configOption { } } +// withFeature handles boolean enablement of generator-level functionality. +// The `F_` prefix is not passed as part of the string, only the remaining substring. +func withFeature(s string) configOption { + fID := featureID(s) + if _, ok := featureRegistry[fID]; !ok { + return func(cfg *generatorConfig) error { + return fmt.Errorf("no such feature is registered in the generator: %q", s) + } + } + return func(cfg *generatorConfig) error { + if cfg.featureEnablement == nil { + cfg.featureEnablement = make(map[featureID]struct{}) + } + cfg.featureEnablement[fID] = struct{}{} + return nil + } +} + // Utility function for stringifying the Transport enum func (t transport) String() string { switch t { diff --git a/internal/gengapic/options_test.go b/internal/gengapic/options_test.go index ecab047d2c..a3f87de62a 100644 --- a/internal/gengapic/options_test.go +++ b/internal/gengapic/options_test.go @@ -15,7 +15,6 @@ package gengapic import ( - "reflect" "testing" "github.com/google/go-cmp/cmp" @@ -142,22 +141,55 @@ func TestParseOptions(t *testing.T) { }, expectErr: false, }, + { + // Test empty parameter in the CSV. + param: "go-gapic-package=path/to/imp;pkg,F_invalid_feature", + expectErr: true, + }, + { + // single feature enablement + param: "go-gapic-package=path;pkg,F_ordered_routing_headers", + expectedCfg: &generatorConfig{ + transports: []transport{grpc}, + pkgPath: "path", + pkgName: "pkg", + outDir: "path", + featureEnablement: map[featureID]struct{}{ + OrderedRoutingHeadersFeature: struct{}{}, + }, + }, + }, + { + // multiple feature enablement + param: "go-gapic-package=path;pkg,F_ordered_routing_headers,F_wrapper_types_for_page_size", + expectedCfg: &generatorConfig{ + transports: []transport{grpc}, + pkgPath: "path", + pkgName: "pkg", + outDir: "path", + featureEnablement: map[featureID]struct{}{ + OrderedRoutingHeadersFeature: struct{}{}, + WrapperTypesForPageSizeFeature: struct{}{}, + }, + }, + }, } { - opts, err := configFromRequest(&tst.param) - if tst.expectErr && err == nil { - t.Errorf("parseOptions(%s) expected error", tst.param) - continue - } + t.Run(tst.param, func(t *testing.T) { - if !tst.expectErr && err != nil { - t.Errorf("parseOptions(%s) got unexpected error: %v", tst.param, err) - continue - } + gotCfg, err := configFromRequest(&tst.param) + if tst.expectErr && err == nil { + t.Fatalf("parseOptions(%s) expected error", tst.param) + } + + if !tst.expectErr && err != nil { + t.Fatalf("parseOptions(%s) got unexpected error: %v", tst.param, err) + } + + if diff := cmp.Diff(gotCfg, tst.expectedCfg, cmp.AllowUnexported(generatorConfig{}, conf.Config{})); diff != "" { + t.Errorf("got(-), want(+):\n%s", diff) + } + }) - if !reflect.DeepEqual(opts, tst.expectedCfg) { - t.Errorf("parseOptions(%s) = %+v, expected %+v", tst.param, opts, tst.expectedCfg) - continue - } } } diff --git a/internal/gengapic/paging.go b/internal/gengapic/paging.go index 81d499e850..ad0dda1b07 100644 --- a/internal/gengapic/paging.go +++ b/internal/gengapic/paging.go @@ -178,14 +178,6 @@ func (g *generator) getPagingFields(m *descriptorpb.MethodDescriptorProto) (repe return nil, nil, nil } - var wrapperTypesAllowed bool - for p, ok := range enableWrapperTypesForPageSize { - if g.descInfo.ParentFile[m].GetPackage() == p && ok { - wrapperTypesAllowed = true - break - } - } - inType := g.descInfo.Type[m.GetInputType()] if inType == nil { return nil, nil, fmt.Errorf("expected %q to be message type, found %T", m.GetInputType(), inType) @@ -206,7 +198,7 @@ func (g *generator) getPagingFields(m *descriptorpb.MethodDescriptorProto) (repe hasPageToken := false for _, f := range inMsg.GetField() { - candidate, needsWrapper := isPageSizeField(f, wrapperTypesAllowed) + candidate, needsWrapper := isPageSizeField(f, g.featureEnabled(WrapperTypesForPageSizeFeature)) if candidate { if pageSizeField == nil { pageSizeField = f diff --git a/internal/gengapic/paging_test.go b/internal/gengapic/paging_test.go index 2f0965344e..309bcbeffb 100644 --- a/internal/gengapic/paging_test.go +++ b/internal/gengapic/paging_test.go @@ -125,19 +125,6 @@ func TestPagingField(t *testing.T) { // * Response has a string next_page_token field // * Response has one and only one repeated or map field - // This test manipulates the enableWrapperTypesForPageSize allowlist, so ensure we're - // not tainting state after test completes. - origAllowList := make(map[string]bool) - for k, v := range enableWrapperTypesForPageSize { - origAllowList[k] = v - } - - defer func() { - enableWrapperTypesForPageSize = origAllowList - }() - // Clear the allowlist for this test. - enableWrapperTypesForPageSize = make(map[string]bool) - // Messages validPageSize := &descriptorpb.DescriptorProto{ Name: proto.String("ValidPageSizeRequest"), @@ -501,13 +488,17 @@ func TestPagingField(t *testing.T) { } } - // Re-test, adding the "paging" package to the allowlist. - enableWrapperTypesForPageSize["paging"] = true + // Re-test, enabling the wrapper feature. g, err = newGenerator(&req) if err != nil { t.Fatal(err) } - g.cfg = &generatorConfig{transports: []transport{rest}} + g.cfg = &generatorConfig{ + transports: []transport{rest}, + featureEnablement: map[featureID]struct{}{ + WrapperTypesForPageSizeFeature: struct{}{}, + }, + } for _, tst := range []struct { mthd *descriptorpb.MethodDescriptorProto sizeField *descriptorpb.FieldDescriptorProto // A nil field means this is not a paged method