Skip to content

Commit

Permalink
feat: add a prototype generator (#20)
Browse files Browse the repository at this point in the history
This prototype is meant to be run as a protoc plugin and yield
a HTTP/JSON client library. It is missing many features and has
many todos. Currently this is only tested by one "golden" style
test validates generated output matches cached testdata.
  • Loading branch information
codyoss authored Oct 30, 2024
1 parent 1e1ed30 commit 77720de
Show file tree
Hide file tree
Showing 27 changed files with 3,201 additions and 15 deletions.
19 changes: 19 additions & 0 deletions generator/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Copyright 2024 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.

golden:
go test github.com/googleapis/google-cloud-rust/generator/cmd/protoc-gen-gclient -update-golden

test:
go test ./...
28 changes: 28 additions & 0 deletions generator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# generator

A tool for generating client libraries.

## Example Run

Run the following command from the generator directory:

```bash
go install github.com/googleapis/google-cloud-rust/generator/cmd/protoc-gen-gclient
protoc -I cmd/protoc-gen-gclient/testdata/smprotos \
-I /path/to/googleapis \
--gclient_out=. \
--gclient_opt=capture-input=true,language=rust \
cmd/protoc-gen-gclient/testdata/smprotos/resources.proto \
cmd/protoc-gen-gclient/testdata/smprotos/service.proto
```

or to playback an old input without the need for `protoc`:

```bash
go run github.com/googleapis/google-cloud-rust/generator/cmd/protoc-gen-gclient -input-path=cmd/protoc-gen-gclient/testdata/rust/rust.bin
```

## General TODOs

- convert proto links into nice rustdoc
- fix documentation indentation after first line
141 changes: 141 additions & 0 deletions generator/cmd/protoc-gen-gclient/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright 2024 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 main

import (
"flag"
"fmt"
"io"
"log/slog"
"os"
"slices"
"strconv"
"strings"
"time"

"github.com/googleapis/google-cloud-rust/generator/src/genclient"
"github.com/googleapis/google-cloud-rust/generator/src/genclient/translator/protobuf"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/pluginpb"
)

func main() {
inputPath := flag.String("input-path", "", "the path to a binary input file in the format of pluginpb.CodeGeneratorRequest")
outDir := flag.String("out-dir", "", "the path to the output directory")
templateDir := flag.String("template-dir", "templates/", "the path to the template directory")
flag.Parse()

if err := run(*inputPath, *outDir, *templateDir); err != nil {
slog.Error(err.Error())
os.Exit(1)
}
slog.Info("Generation Completed Successfully")
}

func run(inputPath, outDir, templateDir string) error {
var reqBytes []byte
var err error
if inputPath == "" {
reqBytes, err = io.ReadAll(os.Stdin)
if err != nil {
return err
}
} else {
reqBytes, err = os.ReadFile(inputPath)
if err != nil {
return err
}
}

genReq := &pluginpb.CodeGeneratorRequest{}
if err := proto.Unmarshal(reqBytes, genReq); err != nil {
return err
}

opts, err := parseOpts(genReq.GetParameter())
if err != nil {
return err
}

if opts.CaptureInput {
// Remove capture-input param from the captured input
ss := slices.DeleteFunc(strings.Split(genReq.GetParameter(), ","), func(s string) bool {
return strings.Contains(s, "capture-input")
})
genReq.Parameter = proto.String(strings.Join(ss, ","))
reqBytes, err = proto.Marshal(genReq)
if err != nil {
return err
}
if err := os.WriteFile(fmt.Sprintf("sample-input-%s.bin", time.Now().Format(time.RFC3339)), reqBytes, 0644); err != nil {
return err
}
}

req, err := protobuf.NewTranslator(&protobuf.Options{
Request: genReq,
OutDir: outDir,
Language: opts.Language,
TemplateDir: templateDir,
}).Translate()
if err != nil {
return err
}

resp := protobuf.NewCodeGeneratorResponse(genclient.Generate(req))
outBytes, err := proto.Marshal(resp)
if err != nil {
return err
}
if _, err := os.Stdout.Write(outBytes); err != nil {
return err
}

return nil
}

type protobufOptions struct {
CaptureInput bool
Language string
}

func parseOpts(optStr string) (*protobufOptions, error) {
opts := &protobufOptions{}
for _, s := range strings.Split(strings.TrimSpace(optStr), ",") {
if s == "" {
slog.Warn("empty option string, skipping")
continue
}
sp := strings.Split(s, "=")
if len(sp) > 2 {
slog.Warn("too many `=` in option string, skipping", "option", s)
continue
}
switch sp[0] {
case "capture-input":
b, err := strconv.ParseBool(sp[1])
if err != nil {
slog.Error("invalid bool in option string, skipping", "option", s)
return nil, err
}
opts.CaptureInput = b
case "language":
opts.Language = strings.ToLower(strings.TrimSpace(sp[1]))
default:
slog.Warn("unknown option", "option", s)
}
}
return opts, nil
}
73 changes: 73 additions & 0 deletions generator/cmd/protoc-gen-gclient/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright 2024 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 main

import (
"flag"
"os"
"path/filepath"
"testing"

"github.com/google/go-cmp/cmp"
)

var updateGolden = flag.Bool("update-golden", false, "update golden files")

func TestMain(m *testing.M) {
flag.Parse()
os.Exit(m.Run())
}

func TestRun_Rust(t *testing.T) {
tDir := t.TempDir()
if err := run("testdata/rust/rust.bin", tDir, "../../templates"); err != nil {
t.Fatal(err)
}
diff(t, "testdata/rust/golden", tDir)
}

func diff(t *testing.T, goldenDir, outputDir string) {
files, err := os.ReadDir(outputDir)
if err != nil {
t.Fatal(err)
}
if *updateGolden {
for _, f := range files {
b, err := os.ReadFile(filepath.Join(outputDir, f.Name()))
if err != nil {
t.Fatal(err)
}
outFileName := filepath.Join(goldenDir, f.Name())
t.Logf("writing golden file %s", outFileName)
if err := os.WriteFile(outFileName, b, os.ModePerm); err != nil {
t.Fatal(err)
}
}
return
}
for _, f := range files {
want, err := os.ReadFile(filepath.Join(goldenDir, f.Name()))
if err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(outputDir, f.Name()))
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("mismatch(-want, +got): %s", diff)
}
}
}
97 changes: 97 additions & 0 deletions generator/cmd/protoc-gen-gclient/testdata/rust/golden/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
use std::sync::Arc;

pub mod model;

#[derive(Clone, Debug)]
pub struct Client {
inner: Arc<ClientRef>,
}

#[derive(Debug)]
struct ClientRef {
http_client: reqwest::Client,
token: String,
}

impl Client {
pub fn new(tok: String) -> Self {
let client = reqwest::Client::builder().build().unwrap();
let inner = ClientRef {
http_client: client,
token: tok,
};
Self {
inner: Arc::new(inner),
}
}

/// Secret Manager Service
///
/// Manages secrets and operations using those secrets. Implements a REST
/// model with the following objects:
///
/// * [Secret][google.cloud.secretmanager.v1.Secret]
/// * [SecretVersion][google.cloud.secretmanager.v1.SecretVersion]
pub fn secret_manager_service(&self) -> SecretManagerService {
SecretManagerService {
client: self.clone(),
base_path: "https://secretmanager.googleapis.com/".to_string(),
}
}
}

/// Secret Manager Service
///
/// Manages secrets and operations using those secrets. Implements a REST
/// model with the following objects:
///
/// * [Secret][google.cloud.secretmanager.v1.Secret]
/// * [SecretVersion][google.cloud.secretmanager.v1.SecretVersion]
#[derive(Debug)]
pub struct SecretManagerService {
client: Client,
base_path: String,
}

impl SecretManagerService {

/// Creates a new [Secret][google.cloud.secretmanager.v1.Secret] containing no
/// [SecretVersions][google.cloud.secretmanager.v1.SecretVersion].
pub async fn create_secret(&self, req: model::CreateSecretRequest) -> Result<model::Secret, Box<dyn std::error::Error>> {
let client = self.client.inner.clone();
let res = client.http_client
.post(format!(
"{}/v1/{}/secrets",
self.base_path,
req.parent,
))
.query(&[("alt", "json")])
.query(&[("secretId", req.secret_id.as_str())])
.bearer_auth(&client.token)
.json(&req.secret)
.send().await?;
if !res.status().is_success() {
return Err("sorry the api you are looking for is not available, please try again".into());
}
res.json::<model::Secret>.await?
}

/// Gets metadata for a given [Secret][google.cloud.secretmanager.v1.Secret].
pub async fn get_secret(&self, req: model::GetSecretRequest) -> Result<model::Secret, Box<dyn std::error::Error>> {
let client = self.client.inner.clone();
let res = client.http_client
.get(format!(
"{}/v1/{}",
self.base_path,
req.name,
))
.query(&[("alt", "json")])
.query(&[("name", req.name.as_str())])
.bearer_auth(&client.token)
.send().await?;
if !res.status().is_success() {
return Err("sorry the api you are looking for is not available, please try again".into());
}
res.json::<model::Secret>.await?
}
}
Loading

0 comments on commit 77720de

Please sign in to comment.