diff --git a/internal/librariangen/bazel/parser.go b/internal/librariangen/bazel/parser.go new file mode 100644 index 0000000000..a041903e1c --- /dev/null +++ b/internal/librariangen/bazel/parser.go @@ -0,0 +1,126 @@ +// Copyright 2025 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 +// +// http://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 bazel + +import ( + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" +) + +// Config holds configuration extracted from a googleapis BUILD.bazel file. +type Config struct { + grpcServiceConfig string + restNumericEnums bool + serviceYAML string + transport string + hasGAPIC bool +} + +// HasGAPIC indicates whether the GAPIC generator should be run. +func (c *Config) HasGAPIC() bool { return c.hasGAPIC } + +// ServiceYAML is the client config file in the API version directory in googleapis. +func (c *Config) ServiceYAML() string { return c.serviceYAML } + +// GRPCServiceConfig is name of the gRPC service config JSON file. +func (c *Config) GRPCServiceConfig() string { return c.grpcServiceConfig } + +// Transport is typically one of "grpc", "rest" or "grpc+rest". +func (c *Config) Transport() string { return c.transport } + +// HasRESTNumericEnums indicates whether the generated client should support numeric enums. +func (c *Config) HasRESTNumericEnums() bool { return c.restNumericEnums } + +// Validate ensures that the configuration is valid. +func (c *Config) Validate() error { + if c.hasGAPIC { + if c.serviceYAML == "" { + return errors.New("librariangen: serviceYAML is not set") + } + } + return nil +} + +var javaGapicLibraryRE = regexp.MustCompile(`java_gapic_library\((?s:.)*?\)`) +// Parse reads a BUILD.bazel file from the given directory and extracts the +// relevant configuration from the java_gapic_library rule. +func Parse(dir string) (*Config, error) { + c := &Config{} + fp := filepath.Join(dir, "BUILD.bazel") + data, err := os.ReadFile(fp) + if err != nil { + return nil, fmt.Errorf("librariangen: failed to read BUILD.bazel file %s: %w", fp, err) + } + content := string(data) + + gapicLibraryBlock := javaGapicLibraryRE.FindString(content) + if gapicLibraryBlock != "" { + c.hasGAPIC = true + c.grpcServiceConfig = findString(gapicLibraryBlock, "grpc_service_config") + c.serviceYAML = strings.TrimPrefix(findString(gapicLibraryBlock, "service_yaml"), ":") + c.transport = findString(gapicLibraryBlock, "transport") + if c.restNumericEnums, err = findBool(gapicLibraryBlock, "rest_numeric_enums"); err != nil { + return nil, fmt.Errorf("librariangen: failed to parse BUILD.bazel file %s: %w", fp, err) + } + } + if err := c.Validate(); err != nil { + return nil, fmt.Errorf("librariangen: invalid bazel config in %s: %w", dir, err) + } + slog.Debug("librariangen: bazel config loaded", "conf", c) + return c, nil +} + +var reCache = &sync.Map{} + +func getRegexp(key, pattern string) *regexp.Regexp { + val, ok := reCache.Load(key) + if !ok { + val = regexp.MustCompile(pattern) + reCache.Store(key, val) + } + return val.(*regexp.Regexp) +} + +func findString(content, name string) string { + re := getRegexp("findString_"+name, fmt.Sprintf(`%s\s*=\s*(?:"([^"]+)"|'([^']+)'){1}`, name)) + match := re.FindStringSubmatch(content) + if len(match) > 2 { + if match[1] != "" { + return match[1] // Double-quoted + } + return match[2] // Single-quoted + } + slog.Debug("librariangen: failed to find string attr in BUILD.bazel", "name", name) + return "" +} + +func findBool(content, name string) (bool, error) { + re := getRegexp("findBool_"+name, fmt.Sprintf(`%s\s*=\s*(\w+)`, name)) + if match := re.FindStringSubmatch(content); len(match) > 1 { + if b, err := strconv.ParseBool(match[1]); err == nil { + return b, nil + } + return false, fmt.Errorf("librariangen: failed to parse bool attr in BUILD.bazel: %q, got: %q", name, match[1]) + } + slog.Debug("librariangen: failed to find bool attr in BUILD.bazel", "name", name) + return false, nil +} diff --git a/internal/librariangen/bazel/parser_test.go b/internal/librariangen/bazel/parser_test.go new file mode 100644 index 0000000000..992665d76d --- /dev/null +++ b/internal/librariangen/bazel/parser_test.go @@ -0,0 +1,248 @@ +// Copyright 2025 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 +// +// http://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 bazel + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParse(t *testing.T) { + content := ` +java_grpc_library( + name = "asset_java_grpc", + srcs = [":asset_proto"], + deps = [":asset_java_proto"], +) + +java_gapic_library( + name = "asset_java_gapic", + srcs = [":asset_proto_with_info"], + grpc_service_config = "cloudasset_grpc_service_config.json", + rest_numeric_enums = True, + service_yaml = "cloudasset_v1.yaml", + test_deps = [ + ":asset_java_grpc", + "//google/iam/v1:iam_java_grpc", + ], + transport = 'grpc+rest', + deps = [ + ":asset_java_proto", + "//google/api:api_java_proto", + "//google/iam/v1:iam_java_proto", + ], +) +` + tmpDir := t.TempDir() + buildPath := filepath.Join(tmpDir, "BUILD.bazel") + if err := os.WriteFile(buildPath, []byte(content), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + got, err := Parse(tmpDir) + if err != nil { + t.Fatalf("Parse() failed: %v", err) + } + + t.Run("HasGAPIC", func(t *testing.T) { + if !got.HasGAPIC() { + t.Error("HasGAPIC() = false; want true") + } + }) + t.Run("ServiceYAML", func(t *testing.T) { + if want := "cloudasset_v1.yaml"; got.ServiceYAML() != want { + t.Errorf("ServiceYAML() = %q; want %q", got.ServiceYAML(), want) + } + }) + t.Run("GRPCServiceConfig", func(t *testing.T) { + if want := "cloudasset_grpc_service_config.json"; got.GRPCServiceConfig() != want { + t.Errorf("GRPCServiceConfig() = %q; want %q", got.GRPCServiceConfig(), want) + } + }) + t.Run("Transport", func(t *testing.T) { + if want := "grpc+rest"; got.Transport() != want { + t.Errorf("Transport() = %q; want %q", got.Transport(), want) + } + }) + t.Run("HasRESTNumericEnums", func(t *testing.T) { + if !got.HasRESTNumericEnums() { + t.Error("HasRESTNumericEnums() = false; want true") + } + }) +} + +func TestParse_serviceConfigIsTarget(t *testing.T) { + content := ` +java_grpc_library( + name = "asset_java_grpc", + srcs = [":asset_proto"], + deps = [":asset_java_proto"], +) + +java_gapic_library( + name = "asset_java_gapic", + srcs = [":asset_proto_with_info"], + grpc_service_config = "cloudasset_grpc_service_config.json", + rest_numeric_enums = True, + service_yaml = ":cloudasset_v1.yaml", + test_deps = [ + ":asset_java_grpc", + "//google/iam/v1:iam_java_grpc", + ], + transport = "grpc+rest", + deps = [ + ":asset_java_proto", + "//google/api:api_java_proto", + "//google/iam/v1:iam_java_proto", + ], +) +` + tmpDir := t.TempDir() + buildPath := filepath.Join(tmpDir, "BUILD.bazel") + if err := os.WriteFile(buildPath, []byte(content), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + got, err := Parse(tmpDir) + if err != nil { + t.Fatalf("Parse() failed: %v", err) + } + + if want := "cloudasset_v1.yaml"; got.ServiceYAML() != want { + t.Errorf("ServiceYAML() = %q; want %q", got.ServiceYAML(), want) + } +} + +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + cfg *Config + wantErr bool + }{ + { + name: "valid GAPIC", + cfg: &Config{ + hasGAPIC: true, + serviceYAML: "b", + grpcServiceConfig: "c", + transport: "d", + }, + wantErr: false, + }, + { + name: "valid non-GAPIC", + cfg: &Config{}, + wantErr: false, + }, + { + name: "gRPC service config and transport are optional", + cfg: &Config{hasGAPIC: true, serviceYAML: "b"}, + wantErr: false, + }, + { + name: "missing serviceYAML", + cfg: &Config{hasGAPIC: true, grpcServiceConfig: "c", transport: "d"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.cfg.Validate(); (err != nil) != tt.wantErr { + t.Errorf("Config.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestParse_noGapic(t *testing.T) { + content := ` +java_grpc_library( + name = "asset_java_grpc", + srcs = [":asset_proto"], + deps = [":asset_java_proto"], +) +` + tmpDir := t.TempDir() + buildPath := filepath.Join(tmpDir, "BUILD.bazel") + if err := os.WriteFile(buildPath, []byte(content), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + got, err := Parse(tmpDir) + if err != nil { + t.Fatalf("Parse() failed: %v", err) + } + + if got.HasGAPIC() { + t.Error("HasGAPIC() = true; want false") + } +} + +func TestParse_missingSomeAttrs(t *testing.T) { + content := ` +java_gapic_library( + name = "asset_java_gapic", + service_yaml = "cloudasset_v1.yaml", +) +` + tmpDir := t.TempDir() + buildPath := filepath.Join(tmpDir, "BUILD.bazel") + if err := os.WriteFile(buildPath, []byte(content), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + got, err := Parse(tmpDir) + if err != nil { + t.Fatalf("Parse() failed: %v", err) + } + + if got.GRPCServiceConfig() != "" { + t.Errorf("GRPCServiceConfig() = %q; want \"\"", got.GRPCServiceConfig()) + } + if got.Transport() != "" { + t.Errorf("Transport() = %q; want \"\"", got.Transport()) + } + if got.HasRESTNumericEnums() { + t.Error("HasRESTNumericEnums() = true; want false") + } +} + +func TestParse_invalidBoolAttr(t *testing.T) { + content := ` +java_gapic_library( + name = "asset_java_gapic", + rest_numeric_enums = "not-a-bool", +) +` + tmpDir := t.TempDir() + buildPath := filepath.Join(tmpDir, "BUILD.bazel") + if err := os.WriteFile(buildPath, []byte(content), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + _, err := Parse(tmpDir) + if err == nil { + t.Error("Parse() succeeded; want error") + } +} + +func TestParse_noBuildFile(t *testing.T) { + tmpDir := t.TempDir() + _, err := Parse(tmpDir) + if err == nil { + t.Error("Parse() succeeded; want error") + } +} \ No newline at end of file