Skip to content

Commit 2f6c75d

Browse files
authored
feat(librariangen): add generate package (#3952)
Based on https://github.com/googleapis/google-cloud-go/tree/main/internal/librariangen/generate with adaptation for Java. Currently it's just the scaffolding and more work is needed to generate a usable GAPIC library. The `generate` package contains the core logic for the generation process, including: - Reading and parsing the `generate-request.json` from librarian. - Parsing `BUILD.bazel` files in the googleapis repository to extract GAPIC configuration. - Building and executing `protoc` with the `gapic-generator-java` plugin. - Unzipping and restructuring the generated files into the final library layout. A `run-generate-library.sh` script is included for local development and end-to-end testing of the generation process. Additionally, a `go.work` file has been added to the root of the repository to support the multi-module workspace structure.
1 parent 5d91e7a commit 2f6c75d

File tree

10 files changed

+971
-4
lines changed

10 files changed

+971
-4
lines changed

go.work

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
go 1.24.7
2+
3+
use ./internal/librariangen

internal/librariangen/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
workspace/

internal/librariangen/bazel/parser.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828

2929
// Config holds configuration extracted from a googleapis BUILD.bazel file.
3030
type Config struct {
31+
gapicYAML string
3132
grpcServiceConfig string
3233
restNumericEnums bool
3334
serviceYAML string
@@ -38,6 +39,9 @@ type Config struct {
3839
// HasGAPIC indicates whether the GAPIC generator should be run.
3940
func (c *Config) HasGAPIC() bool { return c.hasGAPIC }
4041

42+
// GapicYAML is the GAPIC config file in the API version directory in googleapis.
43+
func (c *Config) GapicYAML() string { return c.gapicYAML }
44+
4145
// ServiceYAML is the client config file in the API version directory in googleapis.
4246
func (c *Config) ServiceYAML() string { return c.serviceYAML }
4347

@@ -81,6 +85,7 @@ func Parse(dir string) (*Config, error) {
8185
if c.restNumericEnums, err = findBool(gapicLibraryBlock, "rest_numeric_enums"); err != nil {
8286
return nil, fmt.Errorf("librariangen: failed to parse BUILD.bazel file %s: %w", fp, err)
8387
}
88+
c.gapicYAML = strings.TrimPrefix(findString(gapicLibraryBlock, "gapic_yaml"), ":")
8489
}
8590
if err := c.Validate(); err != nil {
8691
return nil, fmt.Errorf("librariangen: invalid bazel config in %s: %w", dir, err)

internal/librariangen/bazel/parser_test.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ java_grpc_library(
3131
java_gapic_library(
3232
name = "asset_java_gapic",
3333
srcs = [":asset_proto_with_info"],
34+
gapic_yaml = "cloudasset_gapic.yaml",
3435
grpc_service_config = "cloudasset_grpc_service_config.json",
3536
rest_numeric_enums = True,
3637
service_yaml = "cloudasset_v1.yaml",
@@ -67,6 +68,11 @@ java_gapic_library(
6768
t.Errorf("ServiceYAML() = %q; want %q", got.ServiceYAML(), want)
6869
}
6970
})
71+
t.Run("GapicYAML", func(t *testing.T) {
72+
if want := "cloudasset_gapic.yaml"; got.GapicYAML() != want {
73+
t.Errorf("GapicYAML() = %q; want %q", got.GapicYAML(), want)
74+
}
75+
})
7076
t.Run("GRPCServiceConfig", func(t *testing.T) {
7177
if want := "cloudasset_grpc_service_config.json"; got.GRPCServiceConfig() != want {
7278
t.Errorf("GRPCServiceConfig() = %q; want %q", got.GRPCServiceConfig(), want)
@@ -84,7 +90,7 @@ java_gapic_library(
8490
})
8591
}
8692

87-
func TestParse_serviceConfigIsTarget(t *testing.T) {
93+
func TestParse_configIsTarget(t *testing.T) {
8894
content := `
8995
java_grpc_library(
9096
name = "asset_java_grpc",
@@ -95,6 +101,7 @@ java_grpc_library(
95101
java_gapic_library(
96102
name = "asset_java_gapic",
97103
srcs = [":asset_proto_with_info"],
104+
gapic_yaml = ":cloudasset_gapic.yaml",
98105
grpc_service_config = "cloudasset_grpc_service_config.json",
99106
rest_numeric_enums = True,
100107
service_yaml = ":cloudasset_v1.yaml",
@@ -124,6 +131,9 @@ java_gapic_library(
124131
if want := "cloudasset_v1.yaml"; got.ServiceYAML() != want {
125132
t.Errorf("ServiceYAML() = %q; want %q", got.ServiceYAML(), want)
126133
}
134+
if want := "cloudasset_gapic.yaml"; got.GapicYAML() != want {
135+
t.Errorf("GapicYAML() = %q; want %q", got.GapicYAML(), want)
136+
}
127137
}
128138

129139
func TestConfig_Validate(t *testing.T) {
@@ -136,6 +146,7 @@ func TestConfig_Validate(t *testing.T) {
136146
name: "valid GAPIC",
137147
cfg: &Config{
138148
hasGAPIC: true,
149+
gapicYAML: "a",
139150
serviceYAML: "b",
140151
grpcServiceConfig: "c",
141152
transport: "d",
@@ -149,7 +160,7 @@ func TestConfig_Validate(t *testing.T) {
149160
},
150161
{
151162
name: "gRPC service config and transport are optional",
152-
cfg: &Config{hasGAPIC: true, serviceYAML: "b"},
163+
cfg: &Config{hasGAPIC: true, serviceYAML: "b", gapicYAML: "a"},
153164
wantErr: false,
154165
},
155166
{
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
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 generate
16+
17+
import (
18+
"archive/zip"
19+
"context"
20+
"errors"
21+
"fmt"
22+
"io"
23+
"log/slog"
24+
"os"
25+
"path/filepath"
26+
"strings"
27+
28+
"cloud.google.com/java/internal/librariangen/bazel"
29+
"cloud.google.com/java/internal/librariangen/execv"
30+
"cloud.google.com/java/internal/librariangen/protoc"
31+
"cloud.google.com/java/internal/librariangen/request"
32+
)
33+
34+
// Test substitution vars.
35+
var (
36+
bazelParse = bazel.Parse
37+
execvRun = execv.Run
38+
requestParse = request.ParseLibrary
39+
protocBuild = protoc.Build
40+
)
41+
42+
// Config holds the internal librariangen configuration for the generate command.
43+
type Config struct {
44+
// LibrarianDir is the path to the librarian-tool input directory.
45+
// It is expected to contain the generate-request.json file.
46+
LibrarianDir string
47+
// InputDir is the path to the .librarian/generator-input directory from the
48+
// language repository.
49+
InputDir string
50+
// OutputDir is the path to the empty directory where librariangen writes
51+
// its output.
52+
OutputDir string
53+
// SourceDir is the path to a complete checkout of the googleapis repository.
54+
SourceDir string
55+
}
56+
57+
// Validate ensures that the configuration is valid.
58+
func (c *Config) Validate() error {
59+
if c.LibrarianDir == "" {
60+
return errors.New("librariangen: librarian directory must be set")
61+
}
62+
if c.InputDir == "" {
63+
return errors.New("librariangen: input directory must be set")
64+
}
65+
if c.OutputDir == "" {
66+
return errors.New("librariangen: output directory must be set")
67+
}
68+
if c.SourceDir == "" {
69+
return errors.New("librariangen: source directory must be set")
70+
}
71+
return nil
72+
}
73+
74+
// Generate is the main entrypoint for the `generate` command. It orchestrates
75+
// the entire generation process.
76+
func Generate(ctx context.Context, cfg *Config) error {
77+
if err := cfg.Validate(); err != nil {
78+
return fmt.Errorf("librariangen: invalid configuration: %w", err)
79+
}
80+
slog.Debug("librariangen: generate command started")
81+
defer cleanupIntermediateFiles(cfg.OutputDir)
82+
83+
generateReq, err := readGenerateReq(cfg.LibrarianDir)
84+
if err != nil {
85+
return fmt.Errorf("librariangen: failed to read request: %w", err)
86+
}
87+
88+
if err := invokeProtoc(ctx, cfg, generateReq); err != nil {
89+
return fmt.Errorf("librariangen: gapic generation failed: %w", err)
90+
}
91+
92+
// Unzip the generated zip file.
93+
zipPath := filepath.Join(cfg.OutputDir, "java_gapic.zip")
94+
if err := unzip(zipPath, cfg.OutputDir); err != nil {
95+
return fmt.Errorf("librariangen: failed to unzip %s: %w", zipPath, err)
96+
}
97+
98+
// Unzip the inner temp-codegen.srcjar.
99+
srcjarPath := filepath.Join(cfg.OutputDir, "temp-codegen.srcjar")
100+
srcjarDest := filepath.Join(cfg.OutputDir, "java_gapic_srcjar")
101+
if err := unzip(srcjarPath, srcjarDest); err != nil {
102+
return fmt.Errorf("librariangen: failed to unzip %s: %w", srcjarPath, err)
103+
}
104+
105+
if err := restructureOutput(cfg.OutputDir, generateReq.ID); err != nil {
106+
return fmt.Errorf("librariangen: failed to restructure output: %w", err)
107+
}
108+
109+
slog.Debug("librariangen: generate command finished")
110+
return nil
111+
}
112+
113+
// invokeProtoc handles the protoc GAPIC generation logic for the 'generate' CLI command.
114+
// It reads a request file, and for each API specified, it invokes protoc
115+
// to generate the client library. It returns the module path and the path to the service YAML.
116+
func invokeProtoc(ctx context.Context, cfg *Config, generateReq *request.Library) error {
117+
for _, api := range generateReq.APIs {
118+
apiServiceDir := filepath.Join(cfg.SourceDir, api.Path)
119+
slog.Info("processing api", "service_dir", apiServiceDir)
120+
bazelConfig, err := bazelParse(apiServiceDir)
121+
if err != nil {
122+
return fmt.Errorf("librariangen: failed to parse BUILD.bazel for %s: %w", apiServiceDir, err)
123+
}
124+
args, err := protocBuild(apiServiceDir, bazelConfig, cfg.SourceDir, cfg.OutputDir)
125+
if err != nil {
126+
return fmt.Errorf("librariangen: failed to build protoc command for api %q in library %q: %w", api.Path, generateReq.ID, err)
127+
}
128+
if err := execvRun(ctx, args, cfg.OutputDir); err != nil {
129+
return fmt.Errorf("librariangen: protoc failed for api %q in library %q: %w", api.Path, generateReq.ID, err)
130+
}
131+
}
132+
return nil
133+
}
134+
135+
// readGenerateReq reads generate-request.json from the librarian-tool input directory.
136+
// The request file tells librariangen which library and APIs to generate.
137+
// It is prepared by the Librarian tool and mounted at /librarian.
138+
func readGenerateReq(librarianDir string) (*request.Library, error) {
139+
reqPath := filepath.Join(librarianDir, "generate-request.json")
140+
slog.Debug("librariangen: reading generate request", "path", reqPath)
141+
142+
generateReq, err := requestParse(reqPath)
143+
if err != nil {
144+
return nil, err
145+
}
146+
slog.Debug("librariangen: successfully unmarshalled request", "library_id", generateReq.ID)
147+
return generateReq, nil
148+
}
149+
150+
// moveFiles moves all files (and directories) from sourceDir to targetDir.
151+
func moveFiles(sourceDir, targetDir string) error {
152+
files, err := os.ReadDir(sourceDir)
153+
if err != nil {
154+
return fmt.Errorf("librariangen: failed to read dir %s: %w", sourceDir, err)
155+
}
156+
for _, f := range files {
157+
oldPath := filepath.Join(sourceDir, f.Name())
158+
newPath := filepath.Join(targetDir, f.Name())
159+
slog.Debug("librariangen: moving file", "from", oldPath, "to", newPath)
160+
if err := os.Rename(oldPath, newPath); err != nil {
161+
return fmt.Errorf("librariangen: failed to move %s to %s: %w", oldPath, newPath, err)
162+
}
163+
}
164+
return nil
165+
}
166+
167+
func restructureOutput(outputDir, libraryID string) error {
168+
slog.Debug("librariangen: restructuring output directory", "dir", outputDir)
169+
170+
// Define source and destination directories.
171+
gapicSrcDir := filepath.Join(outputDir, "java_gapic_srcjar", "src", "main", "java")
172+
gapicTestDir := filepath.Join(outputDir, "java_gapic_srcjar", "src", "test", "java")
173+
protoSrcDir := filepath.Join(outputDir, "com")
174+
samplesDir := filepath.Join(outputDir, "java_gapic_srcjar", "samples", "snippets")
175+
176+
gapicDestDir := filepath.Join(outputDir, fmt.Sprintf("google-cloud-%s", libraryID), "src", "main", "java")
177+
gapicTestDestDir := filepath.Join(outputDir, fmt.Sprintf("google-cloud-%s", libraryID), "src", "test", "java")
178+
protoDestDir := filepath.Join(outputDir, fmt.Sprintf("proto-google-cloud-%s-v1", libraryID), "src", "main", "java")
179+
samplesDestDir := filepath.Join(outputDir, "samples", "snippets")
180+
181+
// Create destination directories.
182+
destDirs := []string{gapicDestDir, gapicTestDestDir, protoDestDir, samplesDestDir}
183+
for _, dir := range destDirs {
184+
if err := os.MkdirAll(dir, 0755); err != nil {
185+
return err
186+
}
187+
}
188+
189+
// Move files.
190+
moves := map[string]string{
191+
gapicSrcDir: gapicDestDir,
192+
gapicTestDir: gapicTestDestDir,
193+
protoSrcDir: protoDestDir,
194+
samplesDir: samplesDestDir,
195+
}
196+
for src, dest := range moves {
197+
if err := moveFiles(src, dest); err != nil {
198+
return err
199+
}
200+
}
201+
202+
return nil
203+
}
204+
205+
func cleanupIntermediateFiles(outputDir string) {
206+
slog.Debug("librariangen: cleaning up intermediate files", "dir", outputDir)
207+
filesToRemove := []string{
208+
"java_gapic_srcjar",
209+
"com",
210+
"java_gapic.zip",
211+
"temp-codegen.srcjar",
212+
}
213+
for _, file := range filesToRemove {
214+
path := filepath.Join(outputDir, file)
215+
if err := os.RemoveAll(path); err != nil {
216+
slog.Error("librariangen: failed to clean up intermediate file", "path", path, "error", err)
217+
}
218+
}
219+
}
220+
221+
func unzip(src, dest string) error {
222+
r, err := zip.OpenReader(src)
223+
if err != nil {
224+
return err
225+
}
226+
defer r.Close()
227+
228+
for _, f := range r.File {
229+
fpath := filepath.Join(dest, f.Name)
230+
231+
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
232+
return fmt.Errorf("librariangen: illegal file path: %s", fpath)
233+
}
234+
235+
if f.FileInfo().IsDir() {
236+
os.MkdirAll(fpath, os.ModePerm)
237+
continue
238+
}
239+
240+
if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
241+
return err
242+
}
243+
244+
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
245+
if err != nil {
246+
return err
247+
}
248+
249+
rc, err := f.Open()
250+
if err != nil {
251+
outFile.Close()
252+
return err
253+
}
254+
255+
_, copyErr := io.Copy(outFile, rc)
256+
rc.Close() // Error on read-only file close is less critical
257+
closeErr := outFile.Close()
258+
259+
if copyErr != nil {
260+
return copyErr
261+
}
262+
if closeErr != nil {
263+
return closeErr
264+
}
265+
}
266+
return nil
267+
}

0 commit comments

Comments
 (0)