Skip to content

Commit 003f3f3

Browse files
chore: merge main into generate-libraries-main
2 parents 922bd0d + 598de06 commit 003f3f3

13 files changed

Lines changed: 625 additions & 11 deletions

File tree

.github/workflows/downstream.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ on:
99
- library_generation/**
1010
- showcase/**
1111
- test/**
12+
- internal/librariangen/**
1213

1314
name: Downstream Check
1415
jobs:

.github/workflows/librariangen-ci.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ jobs:
2727
run: go version
2828

2929
- name: Run Go tests
30-
run: go test ./...
30+
run: go test -v -coverprofile=coverage.txt -covermode=atomic ./...
3131
working-directory: internal/librariangen
3232

33+
- name: Upload coverage to Codecov
34+
uses: codecov/codecov-action@v5
35+
with:
36+
token: ${{ secrets.CODECOV_TOKEN }}
37+
files: coverage.txt
38+
working-directory: internal/librariangen
39+
flags: librariangen

internal/librariangen/main.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,32 @@ const version = "0.1.0"
2727

2828
// main is the entrypoint for the librariangen CLI.
2929
func main() {
30-
logLevel := slog.LevelInfo
31-
switch os.Getenv("GOOGLE_SDK_JAVA_LOGGING_LEVEL") {
32-
case "debug":
33-
logLevel = slog.LevelDebug
34-
case "quiet":
35-
logLevel = slog.LevelError + 1
36-
}
30+
os.Exit(runCLI(os.Args))
31+
}
32+
33+
func runCLI(args []string) int {
34+
logLevel := parseLogLevel(os.Getenv("GOOGLE_SDK_JAVA_LOGGING_LEVEL"))
3735
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
3836
Level: logLevel,
3937
})))
40-
slog.Info("librariangen: invoked", "args", os.Args)
41-
if err := run(context.Background(), os.Args[1:]); err != nil {
38+
slog.Info("librariangen: invoked", "args", args)
39+
if err := run(context.Background(), args[1:]); err != nil {
4240
slog.Error("librariangen: failed", "error", err)
43-
os.Exit(1)
41+
return 1
4442
}
4543
slog.Info("librariangen: finished successfully")
44+
return 0
45+
}
46+
47+
func parseLogLevel(logLevelEnv string) slog.Level {
48+
switch logLevelEnv {
49+
case "debug":
50+
return slog.LevelDebug
51+
case "quiet":
52+
return slog.LevelError + 1
53+
default:
54+
return slog.LevelInfo
55+
}
4656
}
4757

4858
// run executes the appropriate command based on the CLI's invocation arguments.

internal/librariangen/main_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package main
1616

1717
import (
1818
"context"
19+
"log/slog"
1920
"testing"
2021
)
2122

@@ -91,4 +92,50 @@ func TestRun(t *testing.T) {
9192
}
9293
})
9394
}
95+
}
96+
97+
func TestRunCLI(t *testing.T) {
98+
tests := []struct {
99+
name string
100+
args []string
101+
wantCode int
102+
}{
103+
{
104+
name: "success",
105+
args: []string{"librariangen", "build"},
106+
wantCode: 0,
107+
},
108+
{
109+
name: "failure",
110+
args: []string{"librariangen", "foo"},
111+
wantCode: 1,
112+
},
113+
}
114+
for _, tt := range tests {
115+
t.Run(tt.name, func(t *testing.T) {
116+
if gotCode := runCLI(tt.args); gotCode != tt.wantCode {
117+
t.Errorf("runCLI() = %v, want %v", gotCode, tt.wantCode)
118+
}
119+
})
120+
}
121+
}
122+
123+
func TestParseLogLevel(t *testing.T) {
124+
tests := []struct {
125+
name string
126+
level string
127+
want slog.Level
128+
}{
129+
{"default", "", slog.LevelInfo},
130+
{"debug", "debug", slog.LevelDebug},
131+
{"quiet", "quiet", slog.LevelError + 1},
132+
{"invalid", "foo", slog.LevelInfo},
133+
}
134+
for _, tt := range tests {
135+
t.Run(tt.name, func(t *testing.T) {
136+
if got := parseLogLevel(tt.level); got != tt.want {
137+
t.Errorf("parseLogLevel() = %v, want %v", got, tt.want)
138+
}
139+
})
140+
}
94141
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package protoc
16+
17+
import (
18+
"fmt"
19+
"os"
20+
"path/filepath"
21+
"strings"
22+
23+
)
24+
25+
// ConfigProvider is an interface that describes the configuration needed
26+
// by the Build function. This allows the protoc package to be decoupled
27+
// from the source of the configuration (e.g., Bazel files, JSON, etc.).
28+
type ConfigProvider interface {
29+
ServiceYAML() string
30+
GapicYAML() string
31+
GRPCServiceConfig() string
32+
Transport() string
33+
HasRESTNumericEnums() bool
34+
HasGAPIC() bool
35+
}
36+
37+
// Build constructs the full protoc command arguments for a given API.
38+
func Build(apiServiceDir string, config ConfigProvider, sourceDir, outputDir string) ([]string, error) {
39+
// Gather all .proto files in the API's source directory.
40+
entries, err := os.ReadDir(apiServiceDir)
41+
if err != nil {
42+
return nil, fmt.Errorf("librariangen: failed to read API source directory %s: %w", apiServiceDir, err)
43+
}
44+
45+
var protoFiles []string
46+
for _, entry := range entries {
47+
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".proto" {
48+
protoFiles = append(protoFiles, filepath.Join(apiServiceDir, entry.Name()))
49+
}
50+
}
51+
52+
if len(protoFiles) == 0 {
53+
return nil, fmt.Errorf("librariangen: no .proto files found in %s", apiServiceDir)
54+
}
55+
56+
// Construct the protoc command arguments.
57+
var gapicOpts []string
58+
if config.HasGAPIC() {
59+
if config.ServiceYAML() != "" {
60+
gapicOpts = append(gapicOpts, fmt.Sprintf("api-service-config=%s", filepath.Join(apiServiceDir, config.ServiceYAML())))
61+
}
62+
if config.GapicYAML() != "" {
63+
gapicOpts = append(gapicOpts, fmt.Sprintf("gapic-config=%s", filepath.Join(apiServiceDir, config.GapicYAML())))
64+
}
65+
if config.GRPCServiceConfig() != "" {
66+
gapicOpts = append(gapicOpts, fmt.Sprintf("grpc-service-config=%s", filepath.Join(apiServiceDir, config.GRPCServiceConfig())))
67+
}
68+
if config.Transport() != "" {
69+
gapicOpts = append(gapicOpts, fmt.Sprintf("transport=%s", config.Transport()))
70+
}
71+
if config.HasRESTNumericEnums() {
72+
gapicOpts = append(gapicOpts, "rest-numeric-enums")
73+
}
74+
}
75+
76+
args := []string{
77+
"protoc",
78+
"--experimental_allow_proto3_optional",
79+
}
80+
81+
args = append(args, fmt.Sprintf("--java_out=%s", outputDir))
82+
if config.HasGAPIC() {
83+
args = append(args, fmt.Sprintf("--java_gapic_out=metadata:%s", filepath.Join(outputDir, "java_gapic.zip")))
84+
85+
if len(gapicOpts) > 0 {
86+
args = append(args, "--java_gapic_opt="+strings.Join(gapicOpts, ","))
87+
}
88+
}
89+
90+
args = append(args,
91+
// The -I flag specifies the import path for protoc. All protos
92+
// and their dependencies must be findable from this path.
93+
// The /source mount contains the complete googleapis repository.
94+
"-I="+sourceDir,
95+
)
96+
97+
args = append(args, protoFiles...)
98+
99+
return args, nil
100+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package protoc
16+
17+
import (
18+
"path/filepath"
19+
"strings"
20+
"testing"
21+
22+
"github.com/google/go-cmp/cmp"
23+
)
24+
25+
// mockConfigProvider is a mock implementation of the ConfigProvider interface for testing.
26+
type mockConfigProvider struct {
27+
serviceYAML string
28+
gapicYAML string
29+
grpcServiceConfig string
30+
transport string
31+
restNumericEnums bool
32+
hasGAPIC bool
33+
}
34+
35+
func (m *mockConfigProvider) ServiceYAML() string { return m.serviceYAML }
36+
func (m *mockConfigProvider) GapicYAML() string { return m.gapicYAML }
37+
func (m *mockConfigProvider) GRPCServiceConfig() string { return m.grpcServiceConfig }
38+
func (m *mockConfigProvider) Transport() string { return m.transport }
39+
func (m *mockConfigProvider) HasRESTNumericEnums() bool { return m.restNumericEnums }
40+
func (m *mockConfigProvider) HasGAPIC() bool { return m.hasGAPIC }
41+
42+
func TestBuild(t *testing.T) {
43+
// The testdata directory is a curated version of a valid protoc
44+
// import path that contains all the necessary proto definitions.
45+
sourceDir, err := filepath.Abs("../testdata/generate/source")
46+
if err != nil {
47+
t.Fatalf("failed to get absolute path for sourceDir: %v", err)
48+
}
49+
tests := []struct {
50+
name string
51+
apiPath string
52+
config mockConfigProvider
53+
want []string
54+
}{
55+
{
56+
name: "java_grpc_library rule",
57+
apiPath: "google/cloud/workflows/v1",
58+
config: mockConfigProvider{
59+
transport: "grpc",
60+
grpcServiceConfig: "workflows_grpc_service_config.json",
61+
gapicYAML: "workflows_gapic.yaml",
62+
serviceYAML: "workflows_v1.yaml",
63+
restNumericEnums: true,
64+
hasGAPIC: true,
65+
},
66+
want: []string{
67+
"protoc",
68+
"--experimental_allow_proto3_optional",
69+
"--java_out=/output",
70+
"--java_gapic_out=metadata:/output/java_gapic.zip",
71+
"--java_gapic_opt=" + strings.Join([]string{
72+
"api-service-config=" + filepath.Join(sourceDir, "google/cloud/workflows/v1/workflows_v1.yaml"),
73+
"gapic-config=" + filepath.Join(sourceDir, "google/cloud/workflows/v1/workflows_gapic.yaml"),
74+
"grpc-service-config=" + filepath.Join(sourceDir, "google/cloud/workflows/v1/workflows_grpc_service_config.json"),
75+
"transport=grpc",
76+
"rest-numeric-enums",
77+
}, ","),
78+
"-I=" + sourceDir,
79+
filepath.Join(sourceDir, "google/cloud/workflows/v1/workflows.proto"),
80+
},
81+
},
82+
{
83+
name: "java_proto_library rule with legacy gRPC",
84+
apiPath: "google/cloud/secretmanager/v1beta2",
85+
config: mockConfigProvider{
86+
transport: "grpc",
87+
grpcServiceConfig: "secretmanager_grpc_service_config.json",
88+
serviceYAML: "secretmanager_v1beta2.yaml",
89+
restNumericEnums: true,
90+
hasGAPIC: true,
91+
},
92+
want: []string{
93+
"protoc",
94+
"--experimental_allow_proto3_optional",
95+
"--java_out=/output",
96+
"--java_gapic_out=metadata:/output/java_gapic.zip",
97+
"--java_gapic_opt=" + strings.Join([]string{
98+
"api-service-config=" + filepath.Join(sourceDir, "google/cloud/secretmanager/v1beta2/secretmanager_v1beta2.yaml"),
99+
"grpc-service-config=" + filepath.Join(sourceDir, "google/cloud/secretmanager/v1beta2/secretmanager_grpc_service_config.json"),
100+
"transport=grpc",
101+
"rest-numeric-enums",
102+
}, ","),
103+
"-I=" + sourceDir,
104+
filepath.Join(sourceDir, "google/cloud/secretmanager/v1beta2/secretmanager.proto"),
105+
},
106+
},
107+
{
108+
// Note: we don't have a separate test directory with a proto-only library;
109+
// the config is used to say "don't generate GAPIC".
110+
name: "proto-only",
111+
apiPath: "google/cloud/secretmanager/v1beta2",
112+
config: mockConfigProvider{
113+
hasGAPIC: false,
114+
},
115+
want: []string{
116+
"protoc",
117+
"--experimental_allow_proto3_optional",
118+
"--java_out=/output",
119+
"-I=" + sourceDir,
120+
filepath.Join(sourceDir, "google/cloud/secretmanager/v1beta2/secretmanager.proto"),
121+
},
122+
},
123+
}
124+
125+
for _, tt := range tests {
126+
t.Run(tt.name, func(t *testing.T) {
127+
got, err := Build(filepath.Join(sourceDir, tt.apiPath), &tt.config, sourceDir, "/output")
128+
if err != nil {
129+
t.Fatalf("Build() failed: %v", err)
130+
}
131+
132+
if diff := cmp.Diff(tt.want, got); diff != "" {
133+
t.Errorf("Build() mismatch (-want +got):\n%s", diff)
134+
}
135+
})
136+
}
137+
}

0 commit comments

Comments
 (0)