mirror of
https://codeberg.org/JasterV/granc.git
synced 2026-04-26 18:40:05 +00:00
refactor: separate core logic into a library crate granc-core (#16)
This pull request introduces a significant internal refactor of the `granc` project, decoupling core dynamic gRPC client logic into a new reusable library crate (`granc-core`). It also improves project organization, updates documentation, and enhances workspace configuration. The main CLI functionality is now built atop this new core, making future maintenance and extensibility easier. **Project structure and workspace improvements:** - Created a new crate, `granc-core`, to encapsulate all core dynamic gRPC client logic, including schema resolution, dynamic request dispatch, and reflection support. This enables potential reuse outside the CLI and clarifies project boundaries. (`granc-core/Cargo.toml`, `granc-core/src/client.rs`, [[1]](diffhunk://#diff-dd6f7ed591a1bd2577444d0079c1f56851ef74e3b9df75a86ef4af76681435f6R1-R126) [[2]](diffhunk://#diff-ddab7585cf4c860c9922ed56471bccf5804da60f0ccb174158fd31b9b82457abR1-R46) [[3]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adR1-R186) - Updated workspace configuration in `Cargo.toml` to include `granc-core`, centralize dependency versions, and set workspace-wide package metadata for consistency. (`Cargo.toml`, [Cargo.tomlL2-R26](diffhunk://#diff-2e9d962a08321605940b5a657135052fbcef87b5e360662bb527c96d9a615542L2-R26)) - Adjusted `echo-service` and other crates to use workspace-wide settings for edition and authors. (`echo-service/Cargo.toml`, [echo-service/Cargo.tomlL3-R3](diffhunk://#diff-e74eb8a3bebf341a9bee1cdcd5cd3a50e15998db5a9df9eaf9e7aec341287b1eL3-R3)) **Documentation :** - Added a detailed `README.md` for both the main project and the new `granc-core` library, providing clear installation, usage, and architecture guidance for users and contributors. (`README.md`, `granc-core/README.md`, [[1]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R1-R172) [[2]](diffhunk://#diff-dd6f7ed591a1bd2577444d0079c1f56851ef74e3b9df75a86ef4af76681435f6R1-R126) **Build and tooling updates:** - Updated `Makefile.toml` to use workspace-wide test runs and renamed tasks/binaries for consistency with the new crate layout. (`Makefile.toml`, [[1]](diffhunk://#diff-9375fd04332c86472d7be397ef09428cb86babd8826880a5835bd1d1c1bdbc08L18-R18) [[2]](diffhunk://#diff-9375fd04332c86472d7be397ef09428cb86babd8826880a5835bd1d1c1bdbc08L45-R50) --- **Key changes:** **1. Core library extraction and refactor** - Moved dynamic gRPC client logic (including `GrancClient`, request/response types, and reflection handling) into a new `granc-core` crate, decoupling it from the CLI and preparing for independent publishing. [[1]](diffhunk://#diff-ddab7585cf4c860c9922ed56471bccf5804da60f0ccb174158fd31b9b82457abR1-R46) [[2]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adR1-R186) **2. Workspace and dependency management** - Updated the root `Cargo.toml` to add `granc-core` as a workspace member, centralize dependency versions, and set workspace-wide metadata fields (authors, edition, license, etc.). - Adjusted `echo-service` and new crates to inherit workspace settings for consistency. **3. Documentation** - Updated and added comprehensive `README.md` files for both the main project and the new core library, with installation, usage, and architecture sections. [[1]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R1-R172) [[2]](diffhunk://#diff-dd6f7ed591a1bd2577444d0079c1f56851ef74e3b9df75a86ef4af76681435f6R1-R126) - Introduced a `CHANGELOG.md` to document project history and recent changes. **4. Build and CI tooling** - Updated test and generation commands in `Makefile.toml` to reflect the new workspace structure and binary names. [[1]](diffhunk://#diff-9375fd04332c86472d7be397ef09428cb86babd8826880a5835bd1d1c1bdbc08L18-R18) [[2]](diffhunk://#diff-9375fd04332c86472d7be397ef09428cb86babd8826880a5835bd1d1c1bdbc08L45-R50) **5. Housekeeping** - Removed outdated or redundant files as part of the refactor. [[1]](diffhunk://#diff-06572a96a58dc510037d5efa622f9bec8519bc1beab13c9f251e97e657a9d4edL1) [[2]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5L1) This refactor lays the groundwork for improved maintainability, easier future development, and potential wider adoption of the dynamic gRPC client logic outside the CLI.
This commit is contained in:
parent
09478c6b19
commit
7bc2e4c0a9
35 changed files with 1285 additions and 1184 deletions
|
|
@ -1 +0,0 @@
|
|||
granc/CHANGELOG.md
|
||||
47
CHANGELOG.md
Normal file
47
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## `granc` - [0.2.3](https://github.com/JasterV/granc/compare/granc-v0.2.2...granc-v0.2.3) - 2026-01-21
|
||||
|
||||
### Other
|
||||
|
||||
- **Internal refactor**: Decouple ReflectionClient to possibly publish in a separate crate
|
||||
|
||||
## `granc` - [0.2.2](https://github.com/JasterV/granc/compare/granc-v0.2.1...granc-v0.2.2) - 2026-01-21
|
||||
|
||||
### Other
|
||||
|
||||
- Update README.md
|
||||
|
||||
## `granc` - [0.2.1](https://github.com/JasterV/granc/compare/granc-v0.2.0...granc-v0.2.1) - 2026-01-21
|
||||
|
||||
### Other
|
||||
|
||||
- Update README
|
||||
|
||||
## `granc` - [0.2.0](https://github.com/JasterV/granc/compare/granc-v0.1.0...granc-v0.2.0) - 2026-01-21
|
||||
|
||||
### Added
|
||||
|
||||
- **Automatic Reflection**: The tool now supports automatic reflection, trying to reach the reflection service in the server if the user doesn't provide a file descriptor binary ([#9](https://github.com/JasterV/granc/pull/9))
|
||||
|
||||
## `granc` - 0.1.0 2026-01-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Dynamic gRPC Client**: Implemented a CLI that performs gRPC calls without generating Rust code, bridging JSON payloads to Protobuf binary format at runtime.
|
||||
- **Schema Loading**: Support for loading Protobuf schemas dynamically from binary `FileDescriptorSet` (`.bin` or `.pb`) files.
|
||||
- **Full Streaming Support**: Automatic dispatch for all four gRPC access patterns based on the method descriptor:
|
||||
- Unary (Single Request → Single Response)
|
||||
- Server Streaming (Single Request → Stream)
|
||||
- Client Streaming (Stream → Single Response)
|
||||
- Bidirectional Streaming (Stream → Stream)
|
||||
- **JSON Transcoding**: Custom `tonic::Codec` implementation (`JsonCodec`) to validate and transcode `serde_json::Value` to/from Protobuf bytes on the fly.
|
||||
- **Metadata Support**: Ability to attach custom headers/metadata to requests via the `-H` / `--header` flag.
|
||||
- **Input Validation**: Fast-fail validation that checks if the provided JSON structure is valid before making the network request.
|
||||
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -330,6 +330,16 @@ name = "granc"
|
|||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"granc_core",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tonic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "granc_core"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"echo-service",
|
||||
"futures-util",
|
||||
"http",
|
||||
|
|
@ -1283,6 +1293,6 @@ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
|||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.15"
|
||||
version = "1.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2"
|
||||
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
|
||||
|
|
|
|||
25
Cargo.toml
25
Cargo.toml
|
|
@ -1,3 +1,26 @@
|
|||
[workspace]
|
||||
members = ["granc", "echo-service"]
|
||||
members = ["granc", "granc-core", "echo-service"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
authors = ["Victor Martínez Montané <jaster.victor@gmail.com>"]
|
||||
edition = "2024"
|
||||
homepage = "https://github.com/JasterV/granc"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/JasterV/granc"
|
||||
rust-version = "1.89"
|
||||
version = "0.2.3"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde_json = "1.0.149"
|
||||
tokio = { version = "1.49.0" }
|
||||
|
||||
# Tonic & prost related deps
|
||||
# They must be updated all at once
|
||||
prost = "0.14"
|
||||
prost-reflect = "0.16.3"
|
||||
prost-types = "0.14"
|
||||
tonic = "0.14"
|
||||
tonic-prost = "0.14"
|
||||
tonic-prost-build = "0.14"
|
||||
tonic-reflection = "0.14"
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@ args = ["run", "-p", "granc", "${@}"]
|
|||
[tasks.test]
|
||||
description = "Runs tests for the granc crate only"
|
||||
command = "cargo"
|
||||
# Added '-p granc' to strictly run integration/unit tests for the CLI
|
||||
args = ["nextest", "run", "--no-fail-fast", "-p", "granc"]
|
||||
args = ["nextest", "run", "--no-fail-fast", "--workspace"]
|
||||
|
||||
[tasks.fmt]
|
||||
description = "Formats all source files"
|
||||
|
|
@ -42,14 +41,13 @@ args = [
|
|||
"warnings",
|
||||
]
|
||||
|
||||
|
||||
[tasks.generate_reflection_service]
|
||||
[tasks.generate-reflection-service]
|
||||
description = "Runs a binary that generates a reflection service client from the reflection proto definitions"
|
||||
command = "cargo"
|
||||
args = [
|
||||
"run",
|
||||
"--bin",
|
||||
"generate_reflection_service",
|
||||
"generate-reflection-service",
|
||||
"--features",
|
||||
"gen-proto",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
granc/README.md
|
||||
172
README.md
Normal file
172
README.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# Granc 🦀
|
||||
|
||||
[](https://crates.io/crates/granc)
|
||||
[](https://github.com/JasterV/granc/blob/main/LICENSE)
|
||||
|
||||
> ⚠️ **Status: Experimental**
|
||||
>
|
||||
> This project is currently in a **highly experimental phase**. It is a working prototype intended for testing and development purposes. APIs, command-line arguments, and internal logic are subject to breaking changes. Please use with caution.
|
||||
|
||||
**Granc** (gRPC + Cranc, Crab in Catalan) is a lightweight, dynamic gRPC CLI tool written in Rust.
|
||||
|
||||
It allows you to make gRPC calls to any server using simple JSON payloads, without needing to compile the specific Protobuf files into the client. By loading a `FileDescriptorSet` at runtime, granc acts as a bridge between human-readable JSON and binary Protobuf wire format.
|
||||
|
||||
It is heavily inspired by tools like `grpcurl` but built to leverage the safety and performance of the Rust ecosystem (Tonic + Prost).
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
* **Dynamic Encoding/Decoding**: Transcodes JSON to Protobuf (and vice versa) on the fly using `prost-reflect`.
|
||||
* **Smart Dispatch**: Automatically detects if a call is Unary, Server Streaming, Client Streaming, or Bidirectional based on the descriptor.
|
||||
* **Server Reflection**: Can fetch schemas directly from the server, removing the need to pass a local file descriptor set file (`.bin` or `.pb`).
|
||||
* **Metadata Support**: Easily attach custom headers (authorization, tracing) to your requests.
|
||||
* **Fast Fail Validation**: Validates your JSON *before* hitting the network.
|
||||
* **Zero Compilation Dependencies**: Does not require generating Rust code for your protos. Just point to a descriptor file.
|
||||
* **Tonic 0.14**: Built on the latest stable Rust gRPC stack.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### From Crates.io
|
||||
|
||||
```bash
|
||||
cargo install granc
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
||||
Ensure you have Rust and Cargo installed.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/JasterV/granc
|
||||
cd granc
|
||||
cargo install --path .
|
||||
```
|
||||
|
||||
## 🛠️ Prerequisites
|
||||
|
||||
Granc needs to know the schema of the service you are calling. It can obtain this in two ways:
|
||||
|
||||
1. **Automatic Server Reflection**: If the server has [Server Reflection](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md) enabled, Granc can download the schema automatically.
|
||||
2. **Local Descriptor File**: You can provide a binary `FileDescriptorSet` (`.bin`) generated by `protoc`.
|
||||
|
||||
### Generating Descriptors (Optional)
|
||||
|
||||
If your server does not support reflection, you must generate a descriptor file:
|
||||
|
||||
```bash
|
||||
# Generate descriptor.bin including all imports
|
||||
protoc \
|
||||
--include_imports \
|
||||
--descriptor_set_out=descriptor.bin \
|
||||
--proto_path=. \
|
||||
my_service.proto
|
||||
```
|
||||
|
||||
> **Note**: The `--include_imports` flag is crucial. It ensures that types defined in imported files (like `google/protobuf/timestamp.proto`) are available for reflection.
|
||||
|
||||
## 📖 Usage
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```bash
|
||||
granc [OPTIONS] <URL> <ENDPOINT>
|
||||
```
|
||||
|
||||
### Arguments
|
||||
|
||||
| Argument | Description | Required |
|
||||
| --- | --- | --- |
|
||||
| `<URL>` | Server address (e.g., `http://[::1]:50051`). | **Yes** |
|
||||
| `<ENDPOINT>` | Fully qualified method name (e.g., `my.package.Service/Method`). | **Yes** |
|
||||
|
||||
### Options
|
||||
|
||||
| Flag | Short | Description | Required |
|
||||
| --- | --- | --- | --- |
|
||||
| `--proto-set` | | Path to the binary FileDescriptorSet (`.bin`). | **No** |
|
||||
| `--body` | | The request body in JSON format. | **Yes** |
|
||||
| `--header` | `-H` | Custom header `key:value`. Can be used multiple times. | No |
|
||||
|
||||
### Automatic Server Reflection
|
||||
|
||||
If you omit the `--proto-set` flag, Granc will automatically attempt to connect to the server's reflection service to download the necessary schemas.
|
||||
|
||||
```bash
|
||||
# Using Reflection (no descriptor file needed)
|
||||
granc \
|
||||
--body '{"name": "Ferris"}' \
|
||||
http://localhost:50051 \
|
||||
helloworld.Greeter/SayHello
|
||||
```
|
||||
|
||||
This requires the server to have the [`grpc.reflection.v1`](https://github.com/grpc/grpc-proto/blob/master/grpc/reflection/v1/reflection.proto) service enabled.
|
||||
|
||||
### JSON Body Format
|
||||
|
||||
* **Unary / Server Streaming**: Provide a single JSON object `{ ... }`.
|
||||
* **Client / Bidirectional Streaming**: Provide a JSON array of objects `[ { ... }, { ... } ]`.
|
||||
|
||||
### Examples
|
||||
|
||||
**1. Unary Call (using local descriptor)**
|
||||
|
||||
```bash
|
||||
granc \
|
||||
--proto-set ./descriptor.bin \
|
||||
--body '{"name": "Ferris"}' \
|
||||
http://localhost:50051 \
|
||||
helloworld.Greeter/SayHello
|
||||
```
|
||||
|
||||
**2. Bidirectional Streaming (Chat)**
|
||||
|
||||
```bash
|
||||
granc \
|
||||
--body '[{"text": "Hello"}, {"text": "How are you?"}]' \
|
||||
-H "authorization: Bearer token123" \
|
||||
http://localhost:50051 \
|
||||
chat.ChatService/StreamMessages
|
||||
```
|
||||
|
||||
## 🔮 Roadmap
|
||||
|
||||
* **Interactive Mode**: A REPL for streaming requests interactively.
|
||||
* **Pretty Printing**: Enhanced colored output for JSON responses.
|
||||
* **TLS Support**: Configurable root certificates and client identity.
|
||||
|
||||
## ⚠️ Common Errors
|
||||
|
||||
**1. `Service 'x' not found**`
|
||||
|
||||
* **Cause:** The service name in the command does not match the package defined in your proto file.
|
||||
* **Fix:** Check your `.proto` file. If it has `package my.app;` and `service API {}`, the full name is `my.app.API`.
|
||||
|
||||
**2. `Method 'y' not found in service 'x'**`
|
||||
|
||||
* **Cause:** Typo in the method name or the method doesn't exist.
|
||||
* **Fix:** Ensure case sensitivity matches (e.g., `GetUser` vs `getUser`).
|
||||
|
||||
**3. `h2 protocol error**`
|
||||
|
||||
* **Cause:** This often occurs when the JSON payload fails to encode *after* the connection has already been established, or the server rejected the stream structure.
|
||||
* **Fix:** Double-check your JSON payload against the Protobuf schema.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please run the Makefile checks before submitting a PR:
|
||||
|
||||
```bash
|
||||
cargo make ci # Checks formatting, lints, and runs tests
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
Licensed under either of:
|
||||
|
||||
* Apache License, Version 2.0 ([LICENSE-APACHE](http://www.apache.org/licenses/LICENSE-2.0))
|
||||
* MIT license ([LICENSE-MIT](http://opensource.org/licenses/MIT))
|
||||
|
||||
at your option.
|
||||
|
||||
### Contribution
|
||||
|
||||
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "echo-service"
|
||||
edition = "2024"
|
||||
edition = { workspace = true }
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
46
granc-core/Cargo.toml
Normal file
46
granc-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
[package]
|
||||
authors = { workspace = true }
|
||||
categories = ["network-programming"]
|
||||
description = "Cranc gRPC CLI core library"
|
||||
edition = { workspace = true }
|
||||
homepage = { workspace = true }
|
||||
keywords = ["grpc", "network-programming", "grpc-reflection"]
|
||||
license = { workspace = true }
|
||||
name = "granc_core"
|
||||
publish = true
|
||||
readme = "README.md"
|
||||
repository = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
version = { workspace = true }
|
||||
|
||||
[features]
|
||||
gen-proto = ["dep:tonic-prost-build"]
|
||||
|
||||
[lib]
|
||||
name = "granc_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "generate-reflection-service"
|
||||
path = "bin/generate_reflection_service.rs"
|
||||
required-features = ["gen-proto"]
|
||||
|
||||
[dependencies]
|
||||
futures-util = "0.3.31"
|
||||
http = "1.4.0"
|
||||
http-body = "1.0.1"
|
||||
prost = { workspace = true }
|
||||
prost-reflect = { workspace = true, features = ["serde"] }
|
||||
prost-types = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = "2.0.18"
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
tokio-stream = "0.1.18"
|
||||
tonic = { workspace = true }
|
||||
tonic-prost = { workspace = true }
|
||||
tonic-prost-build = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
echo-service = { path = "../echo-service" }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
tonic-reflection = { workspace = true }
|
||||
126
granc-core/README.md
Normal file
126
granc-core/README.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# Granc Core
|
||||
|
||||
[](https://crates.io/crates/granc_core)
|
||||
[](https://docs.rs/granc_core)
|
||||
[](https://github.com/JasterV/granc/blob/main/LICENSE)
|
||||
|
||||
**`granc-core`** is the foundational library powering the [Granc CLI](https://crates.io/crates/granc). It provides a dynamic gRPC client capability that allows you to interact with *any* gRPC server without needing compile-time Protobuf code generation.
|
||||
|
||||
Instead of strictly typed Rust structs, this library bridges standard `serde_json::Value` payloads directly to Protobuf binary wire format at runtime.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
Add this to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
granc_core = "0.2.3"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde_json = "1"
|
||||
```
|
||||
|
||||
## 🚀 High-Level Usage
|
||||
|
||||
The primary entry point is the [`GrancClient`]. It acts as an orchestrator that:
|
||||
|
||||
1. Connects to a gRPC server.
|
||||
2. Resolves the schema (either from a local file or via Server Reflection).
|
||||
3. Determines the method type (Unary, Server Streaming, etc.).
|
||||
4. Execute the request using JSON.
|
||||
|
||||
### Example: Making a Dynamic Call
|
||||
|
||||
```rust
|
||||
use granc_core::client::{GrancClient, DynamicRequest, DynamicResponse};
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Connect to the server
|
||||
let mut client = GrancClient::connect("http://localhost:50051").await?;
|
||||
|
||||
// Prepare the request
|
||||
// If you don't provide a file_descriptor_set, the client will attempt
|
||||
// to fetch the schema from the server's reflection service automatically.
|
||||
let request = DynamicRequest {
|
||||
service: "helloworld.Greeter".to_string(),
|
||||
method: "SayHello".to_string(),
|
||||
body: json!({ "name": "World" }),
|
||||
headers: vec![],
|
||||
file_descriptor_set: None, // Uses Server Reflection
|
||||
};
|
||||
|
||||
let response = client.dynamic(request).await?;
|
||||
|
||||
match response {
|
||||
DynamicResponse::Unary(Ok(value)) => {
|
||||
println!("Response: {}", value);
|
||||
}
|
||||
DynamicResponse::Unary(Err(status)) => {
|
||||
eprintln!("gRPC Error: {:?}", status);
|
||||
}
|
||||
DynamicResponse::Streaming(Ok(stream)) => {
|
||||
for msg in stream {
|
||||
println!("Stream Msg: {:?}", msg);
|
||||
}
|
||||
}
|
||||
_ => eprintln!("Unexpected response type"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 🛠️ Internal Components
|
||||
|
||||
We expose the internal building blocks of `granc` for developers who need more granular control or want to build their own tools on top of our dynamic transport layer.
|
||||
|
||||
### 1. `GrpcClient` (Generic Transport)
|
||||
|
||||
Standard `tonic` clients are strongly typed (e.g., `client.say_hello(HelloRequest)`).
|
||||
`GrpcClient` is a generic wrapper around `tonic::client::Grpc` that works strictly with `serde_json::Value` and `prost_reflect::MethodDescriptor`.
|
||||
|
||||
It handles the raw HTTP/2 path construction and metadata mapping, providing specific methods for all four gRPC access patterns:
|
||||
|
||||
* `unary`
|
||||
* `server_streaming`
|
||||
* `client_streaming`
|
||||
* `bidirectional_streaming`
|
||||
|
||||
```rust
|
||||
use granc_core::grpc::client::GrpcClient;
|
||||
// You need a method_descriptor from prost_reflect::DescriptorPool
|
||||
// let method_descriptor = ...;
|
||||
|
||||
let mut grpc = GrpcClient::new(channel);
|
||||
let result = grpc.unary(method_descriptor, json_value, headers).await?;
|
||||
|
||||
```
|
||||
|
||||
### 2. `JsonCodec`
|
||||
|
||||
The magic behind the dynamic serialization. This implementation of `tonic::codec::Codec` validates and transcodes JSON to Protobuf bytes (and vice versa) on the fly.
|
||||
|
||||
* **Encoder**: Validates `serde_json::Value` against the input `MessageDescriptor` and serializes it.
|
||||
* **Decoder**: Deserializes bytes into a `DynamicMessage` and converts it back to `serde_json::Value`.
|
||||
|
||||
### 3. `ReflectionClient`
|
||||
|
||||
A client for `grpc.reflection.v1`. It enables runtime schema discovery.
|
||||
|
||||
The `ReflectionClient` is smart enough to handle dependencies. When you ask for a symbol (e.g., `my.package.Service`),
|
||||
it recursively fetches the file defining that symbol and **all** its transitive imports, building a complete `prost_types::FileDescriptorSet` ready for use.
|
||||
|
||||
```rust
|
||||
use granc_core::reflection::client::ReflectionClient;
|
||||
|
||||
let mut reflection = ReflectionClient::new(channel);
|
||||
let fd_set = reflection.file_descriptor_set_by_symbol("my.package.Service").await?;
|
||||
```
|
||||
|
||||
You can then build a `prost_reflect::DescriptorPool` with the returned `prost_types::FileDescriptorSet` to be able to inspect in detail the descriptor.
|
||||
|
||||
## ⚖️ License
|
||||
|
||||
Licensed under either of Apache License, Version 2.0 or MIT license at your option.
|
||||
186
granc-core/src/client.rs
Normal file
186
granc-core/src/client.rs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
//! # Granc Client
|
||||
//!
|
||||
//! This module implements the high-level logic for executing dynamic gRPC requests.
|
||||
//! It acts as the bridge between the user's intent (a JSON body and a method name)
|
||||
//! and the low-level gRPC transport.
|
||||
//!
|
||||
//! ## Responsibilities
|
||||
//!
|
||||
//! 1. **Schema Resolution**: It determines whether to use a provided `FileDescriptorSet`
|
||||
//! or to fetch the schema dynamically using the [`crate::reflection::client::ReflectionClient`].
|
||||
//! 2. **Method Lookup**: It validates that the requested service and method exist within
|
||||
//! the resolved schema.
|
||||
//! 3. **Dispatch**: It inspects the method descriptor to determine the correct gRPC access
|
||||
//! pattern (Unary, Server Streaming, Client Streaming, or Bidirectional) and routes
|
||||
//! the request accordingly.
|
||||
//! 4. **Input Adaptation**: It converts input JSON data into the appropriate stream format
|
||||
//! required by the underlying transport.
|
||||
use crate::{
|
||||
BoxError,
|
||||
grpc::client::{GrpcClient, GrpcRequestError},
|
||||
reflection::client::{ReflectionClient, ReflectionResolveError},
|
||||
};
|
||||
use futures_util::Stream;
|
||||
use http_body::Body as HttpBody;
|
||||
use prost_reflect::{DescriptorError, DescriptorPool};
|
||||
use tokio_stream::StreamExt;
|
||||
use tonic::transport::{Channel, Endpoint};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ClientConnectError {
|
||||
#[error("Invalid URL '{0}': {1}")]
|
||||
InvalidUrl(String, #[source] tonic::transport::Error),
|
||||
#[error("Failed to connect to '{0}': {1}")]
|
||||
ConnectionFailed(String, #[source] tonic::transport::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DynamicRequestError {
|
||||
#[error("Invalid input: '{0}'")]
|
||||
InvalidInput(String),
|
||||
|
||||
#[error("Failed to read descriptor file: '{0}'")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Service '{0}' not found")]
|
||||
ServiceNotFound(String),
|
||||
|
||||
#[error("Method '{0}' not found")]
|
||||
MethodNotFound(String),
|
||||
|
||||
#[error("Reflection resolution failed: '{0}'")]
|
||||
ReflectionResolve(#[from] ReflectionResolveError),
|
||||
|
||||
#[error("Failed to decode file descriptor set: '{0}'")]
|
||||
DescriptorError(#[from] DescriptorError),
|
||||
|
||||
#[error("gRPC client request error: '{0}'")]
|
||||
GrpcRequestError(#[from] GrpcRequestError),
|
||||
}
|
||||
|
||||
pub struct DynamicRequest {
|
||||
pub file_descriptor_set: Option<Vec<u8>>,
|
||||
pub body: serde_json::Value,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub service: String,
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
pub enum DynamicResponse {
|
||||
Unary(Result<serde_json::Value, tonic::Status>),
|
||||
Streaming(Result<Vec<Result<serde_json::Value, tonic::Status>>, tonic::Status>),
|
||||
}
|
||||
|
||||
pub struct GrancClient<S = Channel> {
|
||||
reflection_client: ReflectionClient<S>,
|
||||
grpc_client: GrpcClient<S>,
|
||||
}
|
||||
|
||||
impl GrancClient<Channel> {
|
||||
pub async fn connect(addr: &str) -> Result<Self, ClientConnectError> {
|
||||
let endpoint = Endpoint::new(addr.to_string())
|
||||
.map_err(|e| ClientConnectError::InvalidUrl(addr.to_string(), e))?;
|
||||
|
||||
let channel = endpoint
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| ClientConnectError::ConnectionFailed(addr.to_string(), e))?;
|
||||
|
||||
Ok(Self::new(channel))
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> GrancClient<S>
|
||||
where
|
||||
S: tonic::client::GrpcService<tonic::body::Body> + Clone,
|
||||
S::ResponseBody: HttpBody<Data = tonic::codegen::Bytes> + Send + 'static,
|
||||
<S::ResponseBody as HttpBody>::Error: Into<BoxError> + Send,
|
||||
{
|
||||
pub fn new(service: S) -> Self {
|
||||
let reflection_client = ReflectionClient::new(service.clone());
|
||||
let grpc_client = GrpcClient::new(service);
|
||||
|
||||
Self {
|
||||
reflection_client,
|
||||
grpc_client,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn dynamic(
|
||||
&mut self,
|
||||
request: DynamicRequest,
|
||||
) -> Result<DynamicResponse, DynamicRequestError> {
|
||||
let pool = match request.file_descriptor_set {
|
||||
Some(bytes) => DescriptorPool::decode(bytes.as_slice())?,
|
||||
// If no proto-set file is passed, we'll try to reach the server reflection service
|
||||
None => {
|
||||
let fd_set = self
|
||||
.reflection_client
|
||||
.file_descriptor_set_by_symbol(&request.service)
|
||||
.await?;
|
||||
DescriptorPool::from_file_descriptor_set(fd_set)?
|
||||
}
|
||||
};
|
||||
|
||||
let service = pool
|
||||
.get_service_by_name(&request.service)
|
||||
.ok_or_else(|| DynamicRequestError::ServiceNotFound(request.service))?;
|
||||
|
||||
let method = service
|
||||
.methods()
|
||||
.find(|m| m.name() == request.method)
|
||||
.ok_or_else(|| DynamicRequestError::MethodNotFound(request.method))?;
|
||||
|
||||
match (method.is_client_streaming(), method.is_server_streaming()) {
|
||||
(false, false) => {
|
||||
let result = self
|
||||
.grpc_client
|
||||
.unary(method, request.body, request.headers)
|
||||
.await?;
|
||||
Ok(DynamicResponse::Unary(result))
|
||||
}
|
||||
|
||||
(false, true) => {
|
||||
match self
|
||||
.grpc_client
|
||||
.server_streaming(method, request.body, request.headers)
|
||||
.await?
|
||||
{
|
||||
Ok(stream) => Ok(DynamicResponse::Streaming(Ok(stream.collect().await))),
|
||||
Err(status) => Ok(DynamicResponse::Streaming(Err(status))),
|
||||
}
|
||||
}
|
||||
(true, false) => {
|
||||
let input_stream = json_array_to_stream(request.body)
|
||||
.map_err(DynamicRequestError::InvalidInput)?;
|
||||
let result = self
|
||||
.grpc_client
|
||||
.client_streaming(method, input_stream, request.headers)
|
||||
.await?;
|
||||
Ok(DynamicResponse::Unary(result))
|
||||
}
|
||||
|
||||
(true, true) => {
|
||||
let input_stream = json_array_to_stream(request.body)
|
||||
.map_err(DynamicRequestError::InvalidInput)?;
|
||||
match self
|
||||
.grpc_client
|
||||
.bidirectional_streaming(method, input_stream, request.headers)
|
||||
.await?
|
||||
{
|
||||
Ok(stream) => Ok(DynamicResponse::Streaming(Ok(stream.collect().await))),
|
||||
Err(status) => Ok(DynamicResponse::Streaming(Err(status))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_array_to_stream(
|
||||
json: serde_json::Value,
|
||||
) -> Result<impl Stream<Item = serde_json::Value> + Send + 'static, String> {
|
||||
match json {
|
||||
serde_json::Value::Array(items) => Ok(tokio_stream::iter(items)),
|
||||
_ => Err("Client streaming requires a JSON Array body".to_string()),
|
||||
}
|
||||
}
|
||||
10
granc-core/src/grpc.rs
Normal file
10
granc-core/src/grpc.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
//! # Generic gRPC Transport
|
||||
//!
|
||||
//! This module contains the low-level building blocks for performing gRPC calls using
|
||||
//! dynamic message types.
|
||||
//!
|
||||
//! Unlike standard `tonic` clients which are strongly typed (e.g., `HelloRequest`),
|
||||
//! the components here are designed to work with generic `serde_json::Value` structures,
|
||||
//! transcoding them to Protobuf binary format on the fly.
|
||||
pub mod client;
|
||||
pub mod codec;
|
||||
|
|
@ -1,29 +1,29 @@
|
|||
//! # Generic gRPC Client
|
||||
//!
|
||||
//! This module provides a wrapper around `tonic::client::Grpc` to perform network calls
|
||||
//! without generated Rust types.
|
||||
//! This module wraps a standard `tonic` client to provide a generic interface for
|
||||
//! gRPC communication. It is agnostic to the specific Protobuf messages being exchanged.
|
||||
//!
|
||||
//! Unlike a standard gRPC client where types are known at compile time, this client
|
||||
//! uses the `JsonCodec` to serialize/deserialize data dynamically based on `MethodDescriptor`s.
|
||||
//! ## How it works
|
||||
//!
|
||||
//! ## Key Responsibilities
|
||||
//! The [`GrpcClient`] utilizes the [`super::codec::JsonCodec`] to handle serialization.
|
||||
//! It does not need to know the structure of the data it is sending; it simply ensures
|
||||
//! the connection is established and passes the `serde_json::Value` and `MethodDescriptor`
|
||||
//! to the codec.
|
||||
//!
|
||||
//! * **Connection Management**: Establishes the channel to the remote server.
|
||||
//! * **Dynamic Dispatch**: Provides methods (`unary`, `server_streaming`, etc.) that accept
|
||||
//! raw `serde_json::Value` and `prost_reflect::MethodDescriptor`.
|
||||
//! * **Metadata Handling**: Converts generic string headers into `tonic::metadata` types.
|
||||
//! ## Features
|
||||
//!
|
||||
use crate::core::BoxError;
|
||||
|
||||
//! * **Dynamic Pathing**: Constructs the HTTP/2 path (e.g., `/package.Service/Method`) at runtime.
|
||||
//! * **Metadata Handling**: Converts standard Rust string tuples into Tonic's `MetadataMap` for headers.
|
||||
//! * **Access Patterns**: Provides specific methods for Unary, Server Streaming, Client Streaming,
|
||||
//! and Bidirectional Streaming calls.
|
||||
use super::codec::JsonCodec;
|
||||
use crate::BoxError;
|
||||
use futures_util::Stream;
|
||||
use http_body::Body as HttpBody;
|
||||
use prost_reflect::MethodDescriptor;
|
||||
use std::str::FromStr;
|
||||
use thiserror::Error;
|
||||
use tonic::{
|
||||
Request,
|
||||
client::Grpc,
|
||||
client::GrpcService,
|
||||
metadata::{
|
||||
MetadataKey, MetadataValue,
|
||||
errors::{InvalidMetadataKey, InvalidMetadataValue},
|
||||
|
|
@ -31,18 +31,8 @@ use tonic::{
|
|||
transport::Channel,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_test;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ClientError {
|
||||
#[error("Invalid uri '{addr}' provided: '{source}'")]
|
||||
InvalidUri {
|
||||
addr: String,
|
||||
source: http::uri::InvalidUri,
|
||||
},
|
||||
#[error("Failed to connect: '{0}'")]
|
||||
ConnectionFailed(tonic::transport::Error),
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum GrpcRequestError {
|
||||
#[error("Internal error, the client was not ready: '{0}'")]
|
||||
ClientNotReady(#[source] BoxError),
|
||||
#[error("Invalid metadata (header) key '{key}': '{source}'")]
|
||||
|
|
@ -57,43 +47,23 @@ pub enum ClientError {
|
|||
},
|
||||
}
|
||||
|
||||
/// A generic gRPC client that uses dynamic dispatch via `prost-reflect`.
|
||||
///
|
||||
/// It can wrap any inner service `T` that implements `GrpcService<tonic::body::Body>`,
|
||||
/// such as `tonic::transport::Channel` or a service generated by `tonic-build`.
|
||||
#[derive(Clone)]
|
||||
pub struct GrpcClient<T = Channel> {
|
||||
service: T,
|
||||
}
|
||||
|
||||
/// Implementation for the standard network client using `Channel`.
|
||||
impl GrpcClient<Channel> {
|
||||
/// Connects to the specified gRPC server address.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `addr` - The URI of the server (e.g., `http://localhost:50051`).
|
||||
pub async fn connect(addr: &str) -> Result<Self, ClientError> {
|
||||
let uri =
|
||||
tonic::transport::Uri::from_str(addr).map_err(|source| ClientError::InvalidUri {
|
||||
addr: addr.to_string(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
let channel = Channel::builder(uri)
|
||||
.connect()
|
||||
.await
|
||||
.map_err(ClientError::ConnectionFailed)?;
|
||||
|
||||
Ok(Self { service: channel })
|
||||
}
|
||||
/// A generic client for the gRPC Server Reflection Protocol.
|
||||
pub struct GrpcClient<S = Channel> {
|
||||
client: tonic::client::Grpc<S>,
|
||||
}
|
||||
|
||||
impl<S> GrpcClient<S>
|
||||
where
|
||||
S: tonic::client::GrpcService<tonic::body::Body> + Clone,
|
||||
S::ResponseBody: HttpBody + Send + 'static,
|
||||
<S::ResponseBody as HttpBody>::Error: Into<BoxError>,
|
||||
S: GrpcService<tonic::body::Body>,
|
||||
S::Error: Into<BoxError>,
|
||||
S::ResponseBody: HttpBody<Data = tonic::codegen::Bytes> + Send + 'static,
|
||||
<S::ResponseBody as HttpBody>::Error: Into<BoxError> + Send,
|
||||
{
|
||||
pub fn new(service: S) -> Self {
|
||||
let client = tonic::client::Grpc::new(service);
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// Performs a Unary gRPC call (Single Request -> Single Response).
|
||||
///
|
||||
/// # Returns
|
||||
|
|
@ -101,22 +71,21 @@ where
|
|||
/// * `Ok(Err(Status))` - RPC executed, but server returned an error.
|
||||
/// * `Err(ClientError)` - Failed to send request or connect.
|
||||
pub async fn unary(
|
||||
&self,
|
||||
&mut self,
|
||||
method: MethodDescriptor,
|
||||
payload: serde_json::Value,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<Result<serde_json::Value, tonic::Status>, ClientError> {
|
||||
let mut client = Grpc::new(self.service.clone());
|
||||
client
|
||||
) -> Result<Result<serde_json::Value, tonic::Status>, GrpcRequestError> {
|
||||
self.client
|
||||
.ready()
|
||||
.await
|
||||
.map_err(|e| ClientError::ClientNotReady(e.into()))?;
|
||||
.map_err(|e| GrpcRequestError::ClientNotReady(e.into()))?;
|
||||
|
||||
let codec = JsonCodec::new(method.input(), method.output());
|
||||
let path = http_path(&method);
|
||||
let request = build_request(payload, headers)?;
|
||||
|
||||
match client.unary(request, path, codec).await {
|
||||
match self.client.unary(request, path, codec).await {
|
||||
Ok(response) => Ok(Ok(response.into_inner())),
|
||||
Err(status) => Ok(Err(status)),
|
||||
}
|
||||
|
|
@ -130,25 +99,24 @@ where
|
|||
/// * `Ok(Err(Status))` - RPC executed, but server returned an error.
|
||||
/// * `Err(ClientError)` - Failed to send request or connect.
|
||||
pub async fn server_streaming(
|
||||
&self,
|
||||
&mut self,
|
||||
method: MethodDescriptor,
|
||||
payload: serde_json::Value,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<
|
||||
Result<impl Stream<Item = Result<serde_json::Value, tonic::Status>>, tonic::Status>,
|
||||
ClientError,
|
||||
GrpcRequestError,
|
||||
> {
|
||||
let mut client = Grpc::new(self.service.clone());
|
||||
client
|
||||
self.client
|
||||
.ready()
|
||||
.await
|
||||
.map_err(|e| ClientError::ClientNotReady(e.into()))?;
|
||||
.map_err(|e| GrpcRequestError::ClientNotReady(e.into()))?;
|
||||
|
||||
let codec = JsonCodec::new(method.input(), method.output());
|
||||
let path = http_path(&method);
|
||||
let request = build_request(payload, headers)?;
|
||||
|
||||
match client.server_streaming(request, path, codec).await {
|
||||
match self.client.server_streaming(request, path, codec).await {
|
||||
Ok(response) => Ok(Ok(response.into_inner())),
|
||||
Err(status) => Ok(Err(status)),
|
||||
}
|
||||
|
|
@ -162,22 +130,21 @@ where
|
|||
/// * `Ok(Err(Status))` - RPC executed, but server returned an error.
|
||||
/// * `Err(ClientError)` - Failed to send request or connect.
|
||||
pub async fn client_streaming(
|
||||
&self,
|
||||
&mut self,
|
||||
method: MethodDescriptor,
|
||||
payload_stream: impl Stream<Item = serde_json::Value> + Send + 'static,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<Result<serde_json::Value, tonic::Status>, ClientError> {
|
||||
let mut client = Grpc::new(self.service.clone());
|
||||
client
|
||||
) -> Result<Result<serde_json::Value, tonic::Status>, GrpcRequestError> {
|
||||
self.client
|
||||
.ready()
|
||||
.await
|
||||
.map_err(|e| ClientError::ClientNotReady(e.into()))?;
|
||||
.map_err(|e| GrpcRequestError::ClientNotReady(e.into()))?;
|
||||
|
||||
let codec = JsonCodec::new(method.input(), method.output());
|
||||
let path = http_path(&method);
|
||||
let request = build_request(payload_stream, headers)?;
|
||||
|
||||
match client.client_streaming(request, path, codec).await {
|
||||
match self.client.client_streaming(request, path, codec).await {
|
||||
Ok(response) => Ok(Ok(response.into_inner())),
|
||||
Err(status) => Ok(Err(status)),
|
||||
}
|
||||
|
|
@ -191,25 +158,24 @@ where
|
|||
/// * `Ok(Err(Status))` - RPC executed, but server returned an error.
|
||||
/// * `Err(ClientError)` - Failed to send request or connect.
|
||||
pub async fn bidirectional_streaming(
|
||||
&self,
|
||||
&mut self,
|
||||
method: MethodDescriptor,
|
||||
payload_stream: impl Stream<Item = serde_json::Value> + Send + 'static,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<
|
||||
Result<impl Stream<Item = Result<serde_json::Value, tonic::Status>>, tonic::Status>,
|
||||
ClientError,
|
||||
GrpcRequestError,
|
||||
> {
|
||||
let mut client = Grpc::new(self.service.clone());
|
||||
client
|
||||
self.client
|
||||
.ready()
|
||||
.await
|
||||
.map_err(|e| ClientError::ClientNotReady(e.into()))?;
|
||||
.map_err(|e| GrpcRequestError::ClientNotReady(e.into()))?;
|
||||
|
||||
let codec = JsonCodec::new(method.input(), method.output());
|
||||
let path = http_path(&method);
|
||||
let request = build_request(payload_stream, headers)?;
|
||||
|
||||
match client.streaming(request, path, codec).await {
|
||||
match self.client.streaming(request, path, codec).await {
|
||||
Ok(response) => Ok(Ok(response.into_inner())),
|
||||
Err(status) => Ok(Err(status)),
|
||||
}
|
||||
|
|
@ -221,15 +187,19 @@ fn http_path(method: &MethodDescriptor) -> http::uri::PathAndQuery {
|
|||
http::uri::PathAndQuery::from_str(&path).expect("valid gRPC path")
|
||||
}
|
||||
|
||||
fn build_request<T>(payload: T, headers: Vec<(String, String)>) -> Result<Request<T>, ClientError> {
|
||||
let mut request = Request::new(payload);
|
||||
fn build_request<T>(
|
||||
payload: T,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<tonic::Request<T>, GrpcRequestError> {
|
||||
let mut request = tonic::Request::new(payload);
|
||||
for (k, v) in headers {
|
||||
let key = MetadataKey::from_str(&k).map_err(|source| ClientError::InvalidMetadataKey {
|
||||
key: k.clone(),
|
||||
source,
|
||||
})?;
|
||||
let key =
|
||||
MetadataKey::from_str(&k).map_err(|source| GrpcRequestError::InvalidMetadataKey {
|
||||
key: k.clone(),
|
||||
source,
|
||||
})?;
|
||||
let val = MetadataValue::from_str(&v)
|
||||
.map_err(|source| ClientError::InvalidMetadataValue { key: k, source })?;
|
||||
.map_err(|source| GrpcRequestError::InvalidMetadataValue { key: k, source })?;
|
||||
request.metadata_mut().insert(key, val);
|
||||
}
|
||||
Ok(request)
|
||||
50
granc-core/src/lib.rs
Normal file
50
granc-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
//! # Granc Core
|
||||
//!
|
||||
//! `granc-core` is the foundational library powering the Granc CLI. It provides a dynamic
|
||||
//! gRPC client capable of interacting with any gRPC server without compile-time knowledge
|
||||
//! of the Protobuf schema.
|
||||
//!
|
||||
//! ## Key Components
|
||||
//!
|
||||
//! * **[`GrancClient`]:** The main entry point. It orchestrates schema resolution (via reflection
|
||||
//! or file descriptors) and dispatches requests to the generic gRPC transport.
|
||||
//! * **[`DynamicRequest`] & [`DynamicResponse`]:** The primary data structures for I/O, allowing
|
||||
//! callers to pass JSON data and receive JSON results.
|
||||
//!
|
||||
//! ## Internal clients
|
||||
//!
|
||||
//! We've decided to expose the core clients that we use internally to perform gRPC requests using JSON
|
||||
//! and to interact with a server reflection service.
|
||||
//!
|
||||
//! * **[`GrpcClient`]:** A fully-featured dynamic gRPC client using a custom Json Codec.
|
||||
//! * **[`ReflectionClient`]:** A gRPC Reflection client offering for now only the functionality that we need internally,
|
||||
//! might be extended in the future and packaged as a separate crate if the community finds it useful.
|
||||
//!
|
||||
//! ## JsonCodec
|
||||
//!
|
||||
//! An implementation of `tonic::codec::Codec` that transcodes JSON to Protobuf bytes (and vice versa) on the fly.
|
||||
//!
|
||||
// * **Encoder**: Validates `serde_json::Value` against the input `MessageDescriptor` and serializes it.
|
||||
// * **Decoder**: Deserializes bytes into a `DynamicMessage` and converts it back to `serde_json::Value`.
|
||||
//!
|
||||
//! ## Feature Flags (Internal use only)
|
||||
//!
|
||||
//! * `gen-proto`: Enables support for generating reflection service bindings (internal use).
|
||||
//!
|
||||
//! ## Re-exports
|
||||
//!
|
||||
//! This crate re-exports `prost`, `prost-reflect`, and `tonic` to ensure that consumers
|
||||
//! use compatible versions of these underlying dependencies.
|
||||
//!
|
||||
//! See the README.md for more details about usage.
|
||||
pub mod client;
|
||||
pub mod grpc;
|
||||
pub mod reflection;
|
||||
|
||||
// Re-exports
|
||||
pub use prost;
|
||||
pub use prost_reflect;
|
||||
pub use tonic;
|
||||
|
||||
/// Type alias for the standard boxed error used in generic bounds.
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
|
||||
8
granc-core/src/reflection.rs
Normal file
8
granc-core/src/reflection.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
//! # Server Reflection
|
||||
//!
|
||||
//! This module contains the logic necessary to interact with the gRPC Server Reflection Protocol.
|
||||
//!
|
||||
//! It enables the client to query a server for its own Protobuf schema at runtime, allowing
|
||||
//! `granc` to function without pre-compiled descriptors.
|
||||
pub mod client;
|
||||
mod generated;
|
||||
223
granc-core/src/reflection/client.rs
Normal file
223
granc-core/src/reflection/client.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
//! # Reflection Client
|
||||
//!
|
||||
//! A client implementation for `grpc.reflection.v1`.
|
||||
//!
|
||||
//! This client is responsible for building a complete `FileDescriptorSet` by querying
|
||||
//! a server that supports reflection. It handles the complexity of dependency management by inspecting
|
||||
//! imports and recursively fetching missing files until the entire schema tree for a
|
||||
//! requested symbol is resolved.
|
||||
//!
|
||||
//! ## References
|
||||
//!
|
||||
//! * [gRPC Server Reflection Protocol](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md)//!
|
||||
use super::generated::reflection_v1::{
|
||||
ServerReflectionRequest, ServerReflectionResponse,
|
||||
server_reflection_client::ServerReflectionClient, server_reflection_request::MessageRequest,
|
||||
server_reflection_response::MessageResponse,
|
||||
};
|
||||
use crate::BoxError;
|
||||
use http_body::Body as HttpBody;
|
||||
use prost::Message;
|
||||
use prost_types::{FileDescriptorProto, FileDescriptorSet};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::transport::Channel;
|
||||
use tonic::{Streaming, client::GrpcService};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ReflectionResolveError {
|
||||
#[error(
|
||||
"Failed to start a stream request with the reflection server, reflection might not be supported: '{0}'"
|
||||
)]
|
||||
ServerStreamInitFailed(#[source] tonic::Status),
|
||||
|
||||
#[error("The server stream returned an error status: '{0}'")]
|
||||
ServerStreamFailure(#[source] tonic::Status),
|
||||
|
||||
#[error("Reflection stream closed unexpectedly")]
|
||||
StreamClosed,
|
||||
|
||||
#[error("Internal error: Failed to send request to stream")]
|
||||
SendFailed,
|
||||
|
||||
#[error("Server returned reflection error code {code}: {message}")]
|
||||
ServerError { code: i32, message: String },
|
||||
|
||||
#[error("Protocol error: Received unexpected response type: {0}")]
|
||||
UnexpectedResponseType(String),
|
||||
|
||||
#[error("Failed to decode FileDescriptorProto: {0}")]
|
||||
DecodeError(#[from] prost::DecodeError),
|
||||
}
|
||||
|
||||
// The host defined in the reflection requests doesn't seem to be a mandatory field
|
||||
// and there is no documentation about what it is about.
|
||||
// So we won't enforce it from the user.
|
||||
const EMPTY_HOST: &str = "";
|
||||
|
||||
/// A generic client for the gRPC Server Reflection Protocol.
|
||||
pub struct ReflectionClient<T = Channel> {
|
||||
client: ServerReflectionClient<T>,
|
||||
}
|
||||
|
||||
impl<S> ReflectionClient<S>
|
||||
where
|
||||
S: GrpcService<tonic::body::Body>,
|
||||
S::Error: Into<BoxError>,
|
||||
S::ResponseBody: HttpBody<Data = tonic::codegen::Bytes> + Send + 'static,
|
||||
<S::ResponseBody as HttpBody>::Error: Into<BoxError> + Send,
|
||||
{
|
||||
pub fn new(channel: S) -> Self {
|
||||
let client = ServerReflectionClient::new(channel);
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// Asks the reflection service for the file containing the requested symbol (e.g., `my.package.MyService`).
|
||||
///
|
||||
/// **Recursive Resolution**:
|
||||
/// - The server returns a `FileDescriptorProto`.
|
||||
/// - The client inspects the imports (dependencies) of that file.
|
||||
/// - It recursively requests any missing dependencies until the full `FileDescriptorSet` is built.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(fd_set)` - Successful reflection requests execution.
|
||||
/// * `Err(ReflectionResolveError)` - Failed to request file descriptors to the reflection service.
|
||||
pub async fn file_descriptor_set_by_symbol(
|
||||
&mut self,
|
||||
service_name: &str,
|
||||
) -> Result<FileDescriptorSet, ReflectionResolveError> {
|
||||
// Initialize Stream
|
||||
let (tx, rx) = mpsc::channel(100);
|
||||
|
||||
let mut response_stream = self
|
||||
.client
|
||||
.server_reflection_info(ReceiverStream::new(rx))
|
||||
.await
|
||||
.map_err(ReflectionResolveError::ServerStreamInitFailed)?
|
||||
.into_inner();
|
||||
|
||||
// Send Initial Request
|
||||
let req = ServerReflectionRequest {
|
||||
host: EMPTY_HOST.to_string(),
|
||||
message_request: Some(MessageRequest::FileContainingSymbol(
|
||||
service_name.to_string(),
|
||||
)),
|
||||
};
|
||||
|
||||
tx.send(req)
|
||||
.await
|
||||
.map_err(|_| ReflectionResolveError::SendFailed)?;
|
||||
|
||||
// Fetch all transitive dependencies
|
||||
let file_map = collect_descriptors(&mut response_stream, tx).await?;
|
||||
|
||||
// Build Registry directly
|
||||
let fd_set = FileDescriptorSet {
|
||||
file: file_map.into_values().collect(),
|
||||
};
|
||||
|
||||
Ok(fd_set)
|
||||
}
|
||||
}
|
||||
|
||||
async fn collect_descriptors(
|
||||
response_stream: &mut Streaming<ServerReflectionResponse>,
|
||||
request_channel: mpsc::Sender<ServerReflectionRequest>,
|
||||
) -> Result<HashMap<String, FileDescriptorProto>, ReflectionResolveError> {
|
||||
let mut inflight = 1;
|
||||
let mut collected_files = HashMap::new();
|
||||
let mut requested = HashSet::new();
|
||||
|
||||
while inflight > 0 {
|
||||
let response = response_stream
|
||||
.message()
|
||||
.await
|
||||
.map_err(ReflectionResolveError::ServerStreamFailure)?
|
||||
.ok_or(ReflectionResolveError::StreamClosed)?;
|
||||
|
||||
inflight -= 1;
|
||||
|
||||
match response.message_response {
|
||||
Some(MessageResponse::FileDescriptorResponse(res)) => {
|
||||
let sent_count = process_descriptor_batch(
|
||||
res.file_descriptor_proto,
|
||||
&mut collected_files,
|
||||
&mut requested,
|
||||
&request_channel,
|
||||
)
|
||||
.await?;
|
||||
|
||||
inflight += sent_count;
|
||||
}
|
||||
Some(MessageResponse::ErrorResponse(e)) => {
|
||||
return Err(ReflectionResolveError::ServerError {
|
||||
message: e.error_message,
|
||||
code: e.error_code,
|
||||
});
|
||||
}
|
||||
Some(other) => {
|
||||
return Err(ReflectionResolveError::UnexpectedResponseType(format!(
|
||||
"{:?}",
|
||||
other
|
||||
)));
|
||||
}
|
||||
None => {
|
||||
return Err(ReflectionResolveError::UnexpectedResponseType(
|
||||
"Empty Message".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(collected_files)
|
||||
}
|
||||
|
||||
async fn process_descriptor_batch(
|
||||
raw_protos: Vec<Vec<u8>>,
|
||||
collected_files: &mut HashMap<String, FileDescriptorProto>,
|
||||
requested: &mut HashSet<String>,
|
||||
tx: &mpsc::Sender<ServerReflectionRequest>,
|
||||
) -> Result<usize, ReflectionResolveError> {
|
||||
let mut sent_count = 0;
|
||||
|
||||
for raw in raw_protos {
|
||||
let fd = FileDescriptorProto::decode(raw.as_ref())?;
|
||||
|
||||
if let Some(name) = &fd.name
|
||||
&& !collected_files.contains_key(name)
|
||||
{
|
||||
sent_count += queue_dependencies(&fd, collected_files, requested, tx).await?;
|
||||
|
||||
collected_files.insert(name.clone(), fd);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(sent_count)
|
||||
}
|
||||
|
||||
async fn queue_dependencies(
|
||||
fd: &FileDescriptorProto,
|
||||
collected_files: &HashMap<String, FileDescriptorProto>,
|
||||
requested: &mut HashSet<String>,
|
||||
tx: &mpsc::Sender<ServerReflectionRequest>,
|
||||
) -> Result<usize, ReflectionResolveError> {
|
||||
let mut count = 0;
|
||||
|
||||
for dep in &fd.dependency {
|
||||
if !collected_files.contains_key(dep) && requested.insert(dep.clone()) {
|
||||
let req = ServerReflectionRequest {
|
||||
host: EMPTY_HOST.to_string(),
|
||||
message_request: Some(MessageRequest::FileByFilename(dep.clone())),
|
||||
};
|
||||
|
||||
tx.send(req)
|
||||
.await
|
||||
.map_err(|_| ReflectionResolveError::SendFailed)?;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
use echo_service::{
|
||||
EchoService,
|
||||
pb::{EchoRequest, EchoResponse},
|
||||
};
|
||||
use echo_service::EchoService;
|
||||
use echo_service::pb::{EchoRequest, EchoResponse};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::{Request, Response, Status, Streaming};
|
||||
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
use echo_service::EchoService;
|
||||
use echo_service::pb::{EchoRequest, EchoResponse};
|
||||
|
||||
use futures_util::Stream;
|
||||
use std::pin::Pin;
|
||||
use tokio::sync::mpsc;
|
||||
128
granc-core/tests/granc_client_test.rs
Normal file
128
granc-core/tests/granc_client_test.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
use echo_service::EchoServiceServer;
|
||||
use echo_service::FILE_DESCRIPTOR_SET;
|
||||
use echo_service_impl::EchoServiceImpl;
|
||||
use granc_core::client::{DynamicRequest, DynamicResponse, GrancClient};
|
||||
|
||||
mod echo_service_impl;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unary() {
|
||||
let payload = serde_json::json!({ "message": "hello" });
|
||||
|
||||
let request = DynamicRequest {
|
||||
file_descriptor_set: Some(FILE_DESCRIPTOR_SET.to_vec()),
|
||||
body: payload.clone(),
|
||||
headers: vec![],
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "UnaryEcho".to_string(),
|
||||
};
|
||||
|
||||
let mut client = GrancClient::new(EchoServiceServer::new(EchoServiceImpl));
|
||||
|
||||
let res = client.dynamic(request).await.unwrap();
|
||||
|
||||
match res {
|
||||
DynamicResponse::Unary(Ok(value)) => assert_eq!(value, payload),
|
||||
DynamicResponse::Unary(Err(_)) => {
|
||||
panic!("Received error status for valid unary request")
|
||||
}
|
||||
_ => panic!("Received stream response for unary request"),
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_streaming() {
|
||||
let payload = serde_json::json!({ "message": "stream" });
|
||||
|
||||
let request = DynamicRequest {
|
||||
file_descriptor_set: Some(FILE_DESCRIPTOR_SET.to_vec()),
|
||||
body: payload.clone(),
|
||||
headers: vec![],
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "ServerStreamingEcho".to_string(),
|
||||
};
|
||||
|
||||
let mut client = GrancClient::new(EchoServiceServer::new(EchoServiceImpl));
|
||||
|
||||
let res = client.dynamic(request).await.unwrap();
|
||||
|
||||
match res {
|
||||
DynamicResponse::Streaming(Ok(elems)) => {
|
||||
let results: Vec<_> = elems.into_iter().map(|r| r.unwrap()).collect();
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0]["message"], "stream - seq 0");
|
||||
assert_eq!(results[1]["message"], "stream - seq 1");
|
||||
assert_eq!(results[2]["message"], "stream - seq 2");
|
||||
}
|
||||
DynamicResponse::Streaming(Err(_)) => {
|
||||
panic!("Received error status for valid server streaming request")
|
||||
}
|
||||
_ => panic!("Received unary response for server streaming request"),
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_client_streaming() {
|
||||
let payload = serde_json::json!([
|
||||
{ "message": "A" },
|
||||
{ "message": "B" },
|
||||
{ "message": "C" }
|
||||
]);
|
||||
|
||||
let request = DynamicRequest {
|
||||
file_descriptor_set: Some(FILE_DESCRIPTOR_SET.to_vec()),
|
||||
body: payload.clone(),
|
||||
headers: vec![],
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "ClientStreamingEcho".to_string(),
|
||||
};
|
||||
|
||||
let mut client = GrancClient::new(EchoServiceServer::new(EchoServiceImpl));
|
||||
|
||||
let res = client.dynamic(request).await.unwrap();
|
||||
|
||||
match res {
|
||||
DynamicResponse::Unary(Ok(value)) => {
|
||||
assert_eq!(value, serde_json::json!({"message": "ABC"}))
|
||||
}
|
||||
DynamicResponse::Unary(Err(_)) => {
|
||||
panic!("Received error status for valid client stream request")
|
||||
}
|
||||
_ => panic!("Received stream response for client stream request"),
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bidirectional_streaming() {
|
||||
let payload = serde_json::json!([
|
||||
{ "message": "Ping" },
|
||||
{ "message": "Pong" }
|
||||
]);
|
||||
|
||||
let request = DynamicRequest {
|
||||
file_descriptor_set: Some(FILE_DESCRIPTOR_SET.to_vec()),
|
||||
body: payload.clone(),
|
||||
headers: vec![],
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "BidirectionalEcho".to_string(),
|
||||
};
|
||||
|
||||
let mut client = GrancClient::new(EchoServiceServer::new(EchoServiceImpl));
|
||||
|
||||
let res = client.dynamic(request).await.unwrap();
|
||||
|
||||
match res {
|
||||
DynamicResponse::Streaming(Ok(elems)) => {
|
||||
let results: Vec<_> = elems.into_iter().map(|r| r.unwrap()).collect();
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(results[0]["message"], "echo: Ping");
|
||||
assert_eq!(results[1]["message"], "echo: Pong");
|
||||
}
|
||||
DynamicResponse::Streaming(Err(_)) => {
|
||||
panic!("Received error status for valid bidirectional streaming request")
|
||||
}
|
||||
_ => panic!("Received unary response for bidirectional streaming request"),
|
||||
};
|
||||
}
|
||||
138
granc-core/tests/reflection_client_test.rs
Normal file
138
granc-core/tests/reflection_client_test.rs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
use dummy_echo_service_impl::DummyEchoService;
|
||||
use echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET};
|
||||
use granc_core::reflection::client::{ReflectionClient, ReflectionResolveError};
|
||||
use prost_reflect::DescriptorPool;
|
||||
use tonic::Code;
|
||||
use tonic_reflection::server::v1::ServerReflectionServer;
|
||||
|
||||
mod dummy_echo_service_impl;
|
||||
|
||||
fn setup_reflection_client()
|
||||
-> ReflectionClient<ServerReflectionServer<impl tonic_reflection::server::v1::ServerReflection>> {
|
||||
// Configure the Reflection Service using the descriptor set from echo-service
|
||||
let reflection_service = tonic_reflection::server::Builder::configure()
|
||||
.register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
|
||||
.build_v1()
|
||||
.expect("Failed to setup Reflection Service");
|
||||
|
||||
ReflectionClient::new(reflection_service)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_client_fetches_service_file_descriptor() {
|
||||
let mut client = setup_reflection_client();
|
||||
|
||||
let fd_set = client
|
||||
.file_descriptor_set_by_symbol("echo.EchoService")
|
||||
.await
|
||||
.expect("Failed to fetch file descriptor set by symbol");
|
||||
|
||||
let pool =
|
||||
DescriptorPool::from_file_descriptor_set(fd_set).expect("Failed to build descriptor pool");
|
||||
|
||||
let service = pool
|
||||
.get_service_by_name("echo.EchoService")
|
||||
.expect("Failed to find service in file descriptor");
|
||||
|
||||
assert!(service.methods().all(|f| f.input().name() == "EchoRequest"));
|
||||
assert!(
|
||||
service
|
||||
.methods()
|
||||
.all(|f| f.output().name() == "EchoResponse")
|
||||
);
|
||||
|
||||
let unary_method = service.methods().find(|m| m.name() == "UnaryEcho").unwrap();
|
||||
|
||||
let client_streaming_method = service
|
||||
.methods()
|
||||
.find(|m| m.name() == "ClientStreamingEcho")
|
||||
.unwrap();
|
||||
|
||||
let server_streaming_method = service
|
||||
.methods()
|
||||
.find(|m| m.name() == "ServerStreamingEcho")
|
||||
.unwrap();
|
||||
|
||||
let bidirectional_method = service
|
||||
.methods()
|
||||
.find(|m| m.name() == "BidirectionalEcho")
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!unary_method.is_client_streaming(),
|
||||
"Unary should not be client streaming"
|
||||
);
|
||||
assert!(
|
||||
!unary_method.is_server_streaming(),
|
||||
"Unary should not be server streaming"
|
||||
);
|
||||
|
||||
// Assert Streaming Properties (Client Streaming only)
|
||||
assert!(
|
||||
client_streaming_method.is_client_streaming(),
|
||||
"ClientStreaming MUST be client streaming"
|
||||
);
|
||||
assert!(
|
||||
!client_streaming_method.is_server_streaming(),
|
||||
"ClientStreaming should not be server streaming"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!server_streaming_method.is_client_streaming(),
|
||||
"ServerStreaming should not be client streaming"
|
||||
);
|
||||
assert!(
|
||||
server_streaming_method.is_server_streaming(),
|
||||
"ServerStreaming MUST be server streaming"
|
||||
);
|
||||
|
||||
assert!(
|
||||
bidirectional_method.is_client_streaming(),
|
||||
"Bidirectional MUST be client streaming"
|
||||
);
|
||||
|
||||
assert!(
|
||||
bidirectional_method.is_server_streaming(),
|
||||
"Bidirectional MUST be server streaming"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_service_not_found_error() {
|
||||
let mut client = setup_reflection_client();
|
||||
|
||||
let result: Result<_, _> = client
|
||||
.file_descriptor_set_by_symbol("non.existent.Service")
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ReflectionResolveError::ServerStreamFailure(status)) if status.code() == Code::NotFound
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_does_not_support_reflection() {
|
||||
// Create a server that ONLY hosts the EchoService.
|
||||
// This server does NOT have the Reflection service registered.
|
||||
let server = EchoServiceServer::new(DummyEchoService);
|
||||
let mut client = ReflectionClient::new(server);
|
||||
|
||||
// The client will attempt to call `/grpc.reflection.v1.ServerReflection/ServerReflectionInfo` on this service.
|
||||
let result = client
|
||||
.file_descriptor_set_by_symbol("echo.EchoService")
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(ReflectionResolveError::ServerStreamInitFailed(status)) => {
|
||||
assert_eq!(
|
||||
status.code(),
|
||||
tonic::Code::Unimplemented,
|
||||
"Expected UNIMPLEMENTED status (service not found), but got: {:?}",
|
||||
status
|
||||
);
|
||||
}
|
||||
Err(e) => panic!("Expected StreamInitFailed(Unimplemented), got: {:?}", e),
|
||||
Ok(_) => panic!("Expected error, but got successful registry"),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## `granc` - [0.2.3](https://github.com/JasterV/granc/compare/granc-v0.2.2...granc-v0.2.3) - 2026-01-21
|
||||
|
||||
### Other
|
||||
|
||||
- **Internal refactor**: Decouple ReflectionClient to possibly publish in a separate crate
|
||||
|
||||
## `granc` - [0.2.2](https://github.com/JasterV/granc/compare/granc-v0.2.1...granc-v0.2.2) - 2026-01-21
|
||||
|
||||
### Other
|
||||
|
||||
- Update README.md
|
||||
|
||||
## `granc` - [0.2.1](https://github.com/JasterV/granc/compare/granc-v0.2.0...granc-v0.2.1) - 2026-01-21
|
||||
|
||||
### Other
|
||||
|
||||
- Update README
|
||||
|
||||
## `granc` - [0.2.0](https://github.com/JasterV/granc/compare/granc-v0.1.0...granc-v0.2.0) - 2026-01-21
|
||||
|
||||
### Added
|
||||
|
||||
- **Automatic Reflection**: The tool now supports automatic reflection, trying to reach the reflection service in the server if the user doesn't provide a file descriptor binary ([#9](https://github.com/JasterV/granc/pull/9))
|
||||
|
||||
## `granc` - 0.1.0 2026-01-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Dynamic gRPC Client**: Implemented a CLI that performs gRPC calls without generating Rust code, bridging JSON payloads to Protobuf binary format at runtime.
|
||||
- **Schema Loading**: Support for loading Protobuf schemas dynamically from binary `FileDescriptorSet` (`.bin` or `.pb`) files.
|
||||
- **Full Streaming Support**: Automatic dispatch for all four gRPC access patterns based on the method descriptor:
|
||||
- Unary (Single Request → Single Response)
|
||||
- Server Streaming (Single Request → Stream)
|
||||
- Client Streaming (Stream → Single Response)
|
||||
- Bidirectional Streaming (Stream → Stream)
|
||||
- **JSON Transcoding**: Custom `tonic::Codec` implementation (`JsonCodec`) to validate and transcode `serde_json::Value` to/from Protobuf bytes on the fly.
|
||||
- **Metadata Support**: Ability to attach custom headers/metadata to requests via the `-H` / `--header` flag.
|
||||
- **Input Validation**: Fast-fail validation that checks if the provided JSON structure is valid before making the network request.
|
||||
|
|
@ -1,48 +1,21 @@
|
|||
[package]
|
||||
authors = ["Victor Martínez Montané <jaster.victor@gmail.com>"]
|
||||
authors = { workspace = true }
|
||||
categories = ["network-programming", "command-line-utilities"]
|
||||
description = "A dynamic gRPC CLI tool written in Rust (gRPC + Cranc, Crab in Catalan)"
|
||||
edition = "2024"
|
||||
homepage = "https://github.com/JasterV/granc"
|
||||
edition = { workspace = true }
|
||||
homepage = { workspace = true }
|
||||
keywords = ["cli", "command-line", "grpc", "grpcurl", "curl"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
license = { workspace = true }
|
||||
name = "granc"
|
||||
publish = true
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/JasterV/granc"
|
||||
rust-version = "1.89"
|
||||
version = "0.2.3"
|
||||
default-run = "granc"
|
||||
|
||||
[features]
|
||||
gen-proto = ["dep:tonic-prost-build"]
|
||||
|
||||
[[bin]]
|
||||
name = "granc"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "generate_reflection_service"
|
||||
path = "bin/generate_reflection_service.rs"
|
||||
required-features = ["gen-proto"]
|
||||
readme = "../README.md"
|
||||
repository = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
version = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.54", features = ["derive"] }
|
||||
futures-util = "0.3.31"
|
||||
http = "1.4.0"
|
||||
http-body = "1.0.1"
|
||||
prost = "0.14.3"
|
||||
prost-reflect = { version = "0.16.3", features = ["serde"] }
|
||||
prost-types = "0.14.3"
|
||||
serde_json = "1.0.149"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros"] }
|
||||
tokio-stream = "0.1.18"
|
||||
tonic = "0.14.2"
|
||||
tonic-prost = "0.14.2"
|
||||
tonic-prost-build = { version = "0.14", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
echo-service = { path = "../echo-service" }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] }
|
||||
tonic-reflection = "0.14"
|
||||
granc_core = { path = "../granc-core" }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
tonic = { workspace = true }
|
||||
|
|
|
|||
171
granc/README.md
171
granc/README.md
|
|
@ -1,171 +0,0 @@
|
|||
# Granc 🦀
|
||||
|
||||
[](https://crates.io/crates/granc)
|
||||
|
||||
> ⚠️ **Status: Experimental**
|
||||
>
|
||||
> This project is currently in a **highly experimental phase**. It is a working prototype intended for testing and development purposes. APIs, command-line arguments, and internal logic are subject to breaking changes. Please use with caution.
|
||||
|
||||
**Granc** (gRPC + Cranc, Crab in Catalan) is a lightweight, dynamic gRPC CLI tool written in Rust.
|
||||
|
||||
It allows you to make gRPC calls to any server using simple JSON payloads, without needing to compile the specific Protobuf files into the client. By loading a `FileDescriptorSet` at runtime, granc acts as a bridge between human-readable JSON and binary Protobuf wire format.
|
||||
|
||||
It is heavily inspired by tools like `grpcurl` but built to leverage the safety and performance of the Rust ecosystem (Tonic + Prost).
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
* **Dynamic Encoding/Decoding**: Transcodes JSON to Protobuf (and vice versa) on the fly using `prost-reflect`.
|
||||
* **Smart Dispatch**: Automatically detects if a call is Unary, Server Streaming, Client Streaming, or Bidirectional based on the descriptor.
|
||||
* **Server Reflection**: Can fetch schemas directly from the server, removing the need to pass a local file descriptor set file (`.bin` or `.pb`).
|
||||
* **Metadata Support**: Easily attach custom headers (authorization, tracing) to your requests.
|
||||
* **Fast Fail Validation**: Validates your JSON *before* hitting the network.
|
||||
* **Zero Compilation Dependencies**: Does not require generating Rust code for your protos. Just point to a descriptor file.
|
||||
* **Tonic 0.14**: Built on the latest stable Rust gRPC stack.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### From Crates.io
|
||||
|
||||
```bash
|
||||
cargo install granc
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
||||
Ensure you have Rust and Cargo installed.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/JasterV/granc
|
||||
cd granc
|
||||
cargo install --path .
|
||||
```
|
||||
|
||||
## 🛠️ Prerequisites
|
||||
|
||||
Granc needs to know the schema of the service you are calling. It can obtain this in two ways:
|
||||
|
||||
1. **Automatic Server Reflection**: If the server has [Server Reflection](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md) enabled, Granc can download the schema automatically.
|
||||
2. **Local Descriptor File**: You can provide a binary `FileDescriptorSet` (`.bin`) generated by `protoc`.
|
||||
|
||||
### Generating Descriptors (Optional)
|
||||
|
||||
If your server does not support reflection, you must generate a descriptor file:
|
||||
|
||||
```bash
|
||||
# Generate descriptor.bin including all imports
|
||||
protoc \
|
||||
--include_imports \
|
||||
--descriptor_set_out=descriptor.bin \
|
||||
--proto_path=. \
|
||||
my_service.proto
|
||||
```
|
||||
|
||||
> **Note**: The `--include_imports` flag is crucial. It ensures that types defined in imported files (like `google/protobuf/timestamp.proto`) are available for reflection.
|
||||
|
||||
## 📖 Usage
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```bash
|
||||
granc [OPTIONS] <URL> <ENDPOINT>
|
||||
```
|
||||
|
||||
### Arguments
|
||||
|
||||
| Argument | Description | Required |
|
||||
| --- | --- | --- |
|
||||
| `<URL>` | Server address (e.g., `http://[::1]:50051`). | **Yes** |
|
||||
| `<ENDPOINT>` | Fully qualified method name (e.g., `my.package.Service/Method`). | **Yes** |
|
||||
|
||||
### Options
|
||||
|
||||
| Flag | Short | Description | Required |
|
||||
| --- | --- | --- | --- |
|
||||
| `--proto-set` | | Path to the binary FileDescriptorSet (`.bin`). | **No** |
|
||||
| `--body` | | The request body in JSON format. | **Yes** |
|
||||
| `--header` | `-H` | Custom header `key:value`. Can be used multiple times. | No |
|
||||
|
||||
### Automatic Server Reflection
|
||||
|
||||
If you omit the `--proto-set` flag, Granc will automatically attempt to connect to the server's reflection service to download the necessary schemas.
|
||||
|
||||
```bash
|
||||
# Using Reflection (no descriptor file needed)
|
||||
granc \
|
||||
--body '{"name": "Ferris"}' \
|
||||
http://localhost:50051 \
|
||||
helloworld.Greeter/SayHello
|
||||
```
|
||||
|
||||
This requires the server to have the [`grpc.reflection.v1`](https://github.com/grpc/grpc-proto/blob/master/grpc/reflection/v1/reflection.proto) service enabled.
|
||||
|
||||
### JSON Body Format
|
||||
|
||||
* **Unary / Server Streaming**: Provide a single JSON object `{ ... }`.
|
||||
* **Client / Bidirectional Streaming**: Provide a JSON array of objects `[ { ... }, { ... } ]`.
|
||||
|
||||
### Examples
|
||||
|
||||
**1. Unary Call (using local descriptor)**
|
||||
|
||||
```bash
|
||||
granc \
|
||||
--proto-set ./descriptor.bin \
|
||||
--body '{"name": "Ferris"}' \
|
||||
http://localhost:50051 \
|
||||
helloworld.Greeter/SayHello
|
||||
```
|
||||
|
||||
**2. Bidirectional Streaming (Chat)**
|
||||
|
||||
```bash
|
||||
granc \
|
||||
--body '[{"text": "Hello"}, {"text": "How are you?"}]' \
|
||||
-H "authorization: Bearer token123" \
|
||||
http://localhost:50051 \
|
||||
chat.ChatService/StreamMessages
|
||||
```
|
||||
|
||||
## 🔮 Roadmap
|
||||
|
||||
* **Interactive Mode**: A REPL for streaming requests interactively.
|
||||
* **Pretty Printing**: Enhanced colored output for JSON responses.
|
||||
* **TLS Support**: Configurable root certificates and client identity.
|
||||
|
||||
## ⚠️ Common Errors
|
||||
|
||||
**1. `Service 'x' not found**`
|
||||
|
||||
* **Cause:** The service name in the command does not match the package defined in your proto file.
|
||||
* **Fix:** Check your `.proto` file. If it has `package my.app;` and `service API {}`, the full name is `my.app.API`.
|
||||
|
||||
**2. `Method 'y' not found in service 'x'**`
|
||||
|
||||
* **Cause:** Typo in the method name or the method doesn't exist.
|
||||
* **Fix:** Ensure case sensitivity matches (e.g., `GetUser` vs `getUser`).
|
||||
|
||||
**3. `h2 protocol error**`
|
||||
|
||||
* **Cause:** This often occurs when the JSON payload fails to encode *after* the connection has already been established, or the server rejected the stream structure.
|
||||
* **Fix:** Double-check your JSON payload against the Protobuf schema.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please run the Makefile checks before submitting a PR:
|
||||
|
||||
```bash
|
||||
cargo make ci # Checks formatting, lints, and runs tests
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
Licensed under either of:
|
||||
|
||||
* Apache License, Version 2.0 ([LICENSE-APACHE](http://www.apache.org/licenses/LICENSE-2.0))
|
||||
* MIT license ([LICENSE-MIT](http://opensource.org/licenses/MIT))
|
||||
|
||||
at your option.
|
||||
|
||||
### Contribution
|
||||
|
||||
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
|
||||
|
|
@ -1,19 +1,16 @@
|
|||
//! # Command Line Interface Definition
|
||||
//! # CLI
|
||||
//!
|
||||
//! This module utilizes `clap` to define the command-line arguments and flags
|
||||
//! accepted by the application. It acts as the user-facing entry point, responsible for:
|
||||
//!
|
||||
//! 1. **Parsing**: extracting and ensuring each argument and flag can be parsed to the target Rust types.
|
||||
//! 2. **Conversion**: transforming the raw arguments into the `crate::core::Input` struct used by the core logic.
|
||||
//! This module defines the command-line interface of `granc` using `clap`.
|
||||
//!
|
||||
//! It is responsible for parsing user input and performing validation (e.g., ensuring headers are `key:value`);
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
use granc_core::client::DynamicRequest;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "granc", version, about = "Dynamic gRPC CLI")]
|
||||
pub struct Cli {
|
||||
#[arg(long, help = "Path to the descriptor set (.bin)")]
|
||||
pub proto_set: Option<PathBuf>,
|
||||
#[arg(long, help = "Path to the descriptor set (.bin)", value_parser = parse_file_descriptor_set)]
|
||||
pub file_descriptor_set: Option<Vec<u8>>,
|
||||
|
||||
#[arg(long, help = "JSON body (Object for Unary, Array for Streaming)", value_parser = parse_body)]
|
||||
pub body: serde_json::Value,
|
||||
|
|
@ -28,22 +25,28 @@ pub struct Cli {
|
|||
pub endpoint: (String, String),
|
||||
}
|
||||
|
||||
impl From<Cli> for crate::core::Input {
|
||||
impl From<Cli> for DynamicRequest {
|
||||
/// Converts the raw CLI arguments into the internal `Input` representation.
|
||||
fn from(value: Cli) -> Self {
|
||||
let (service, method) = value.endpoint;
|
||||
|
||||
Self {
|
||||
proto_set: value.proto_set,
|
||||
file_descriptor_set: value.file_descriptor_set,
|
||||
body: value.body,
|
||||
headers: value.headers,
|
||||
url: value.url,
|
||||
service,
|
||||
method,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_file_descriptor_set(path: &str) -> Result<Vec<u8>, String> {
|
||||
let path = path.trim();
|
||||
|
||||
std::fs::read(path)
|
||||
.map_err(|err| format!("Failed to read file descriptor set at path '{path}': '{err}'"))
|
||||
}
|
||||
|
||||
fn parse_endpoint(value: &str) -> Result<(String, String), String> {
|
||||
let (service, method) = value.split_once('/').ok_or_else(|| {
|
||||
format!("Invalid endpoint format: '{value}'. Expected 'package.Service/Method'",)
|
||||
|
|
|
|||
|
|
@ -1,162 +0,0 @@
|
|||
//! # Core Orchestration Layer
|
||||
//!
|
||||
//! This module is the "brain" of the application. It orchestrates the flow of a single execution:
|
||||
//!
|
||||
//! 1. **Schema Resolution**: It determines whether to load descriptors from a local file
|
||||
//! or fetch them dynamically from the server via Reflection.
|
||||
//! 2. **Method Lookup**: It locates the specific `MethodDescriptor` within the given descriptor registry.
|
||||
//! 3. **Dispatch**: It initializes the `GrpcClient` and selects the correct handler
|
||||
//! (Unary, ServerStreaming, etc.) based on the grpc method type.
|
||||
mod client;
|
||||
mod codec;
|
||||
mod reflection;
|
||||
|
||||
use client::GrpcClient;
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use prost_reflect::MethodDescriptor;
|
||||
use reflection::{DescriptorRegistry, ReflectionClient};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::core::{
|
||||
client::ClientError,
|
||||
reflection::{
|
||||
client::{ReflectionConnectError, ReflectionResolveError},
|
||||
registry::DescriptorError,
|
||||
},
|
||||
};
|
||||
|
||||
/// Type alias for the standard boxed error used in generic bounds.
|
||||
pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
|
||||
|
||||
/// Request parameters (URL, Body, Headers... etc.).
|
||||
pub struct Input {
|
||||
pub proto_set: Option<PathBuf>,
|
||||
pub body: serde_json::Value,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub url: String,
|
||||
pub service: String,
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
/// A unified enum representing the result, whether it's a single value or a stream
|
||||
pub enum Output {
|
||||
Unary(Result<serde_json::Value, tonic::Status>),
|
||||
Streaming(Result<Vec<Result<serde_json::Value, tonic::Status>>, tonic::Status>),
|
||||
}
|
||||
|
||||
/// Defines all the possible reasons the execution could fail for.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CoreError {
|
||||
#[error("Descriptor registry error: {0}")]
|
||||
Registry(#[from] DescriptorError),
|
||||
|
||||
#[error("Reflection connection failed: {0}")]
|
||||
ReflectionConnect(#[from] ReflectionConnectError),
|
||||
|
||||
#[error("Reflection resolution failed: {0}")]
|
||||
ReflectionResolve(#[from] ReflectionResolveError),
|
||||
|
||||
#[error("gRPC client error: {0}")]
|
||||
Client(#[from] ClientError),
|
||||
|
||||
#[error("Invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
}
|
||||
|
||||
/// Executes the gRPC CLI logic.
|
||||
///
|
||||
/// This function handles the high-level workflow: loading the descriptor registry either locally or using server reflection,
|
||||
/// connecting to the server, and dispatching the request to the appropriate streaming handler.
|
||||
pub async fn run(input: Input) -> Result<Output, CoreError> {
|
||||
let registry = match input.proto_set {
|
||||
Some(path) => DescriptorRegistry::from_file(path)?,
|
||||
// If no proto-set file is passed, we'll try to reach the server reflection service
|
||||
None => {
|
||||
let mut client = ReflectionClient::connect(input.url.clone()).await?;
|
||||
let fd_set = client.file_descriptor_set_by_symbol(&input.service).await?;
|
||||
DescriptorRegistry::from_file_descriptor_set(fd_set)?
|
||||
}
|
||||
};
|
||||
|
||||
let method = registry.get_method_descriptor(&input.service, &input.method)?;
|
||||
|
||||
let client = GrpcClient::connect(&input.url).await?;
|
||||
|
||||
println!("Calling {}/{}...", input.service, input.method);
|
||||
|
||||
match (method.is_client_streaming(), method.is_server_streaming()) {
|
||||
(false, false) => handle_unary(client, method, input.body, input.headers).await,
|
||||
(false, true) => handle_server_stream(client, method, input.body, input.headers).await,
|
||||
(true, false) => handle_client_stream(client, method, input.body, input.headers).await,
|
||||
(true, true) => {
|
||||
handle_bidirectional_stream(client, method, input.body, input.headers).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
async fn handle_unary(
|
||||
client: GrpcClient,
|
||||
method: MethodDescriptor,
|
||||
body: serde_json::Value,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<Output, CoreError> {
|
||||
let result = client.unary(method, body, headers).await?;
|
||||
Ok(Output::Unary(result))
|
||||
}
|
||||
|
||||
async fn handle_server_stream(
|
||||
client: GrpcClient,
|
||||
method: MethodDescriptor,
|
||||
body: serde_json::Value,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<Output, CoreError> {
|
||||
match client.server_streaming(method, body, headers).await? {
|
||||
Ok(stream) => Ok(Output::Streaming(Ok(stream.collect().await))),
|
||||
Err(status) => Ok(Output::Streaming(Err(status))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_client_stream(
|
||||
client: GrpcClient,
|
||||
method: MethodDescriptor,
|
||||
body: serde_json::Value,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<Output, CoreError> {
|
||||
let input_stream = json_array_to_stream(body)?;
|
||||
|
||||
let result = client
|
||||
.client_streaming(method, input_stream, headers)
|
||||
.await?;
|
||||
|
||||
Ok(Output::Unary(result))
|
||||
}
|
||||
|
||||
async fn handle_bidirectional_stream(
|
||||
client: GrpcClient,
|
||||
method: MethodDescriptor,
|
||||
body: serde_json::Value,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<Output, CoreError> {
|
||||
let input_stream = json_array_to_stream(body)?;
|
||||
|
||||
match client
|
||||
.bidirectional_streaming(method, input_stream, headers)
|
||||
.await?
|
||||
{
|
||||
Ok(stream) => Ok(Output::Streaming(Ok(stream.collect().await))),
|
||||
Err(status) => Ok(Output::Streaming(Err(status))),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_array_to_stream(
|
||||
json: serde_json::Value,
|
||||
) -> Result<impl Stream<Item = serde_json::Value> + Send + 'static, CoreError> {
|
||||
match json {
|
||||
serde_json::Value::Array(items) => Ok(tokio_stream::iter(items)),
|
||||
_ => Err(CoreError::InvalidInput(
|
||||
"Client streaming requires a JSON Array body".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
use crate::core::client::GrpcClient;
|
||||
use crate::core::reflection::DescriptorRegistry;
|
||||
use echo_service::EchoServiceServer;
|
||||
use echo_service::FILE_DESCRIPTOR_SET;
|
||||
use echo_service_impl::EchoServiceImpl;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
mod echo_service_impl;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unary() {
|
||||
let registry = DescriptorRegistry::from_bytes(FILE_DESCRIPTOR_SET).unwrap();
|
||||
|
||||
let method = registry
|
||||
.get_method_descriptor("echo.EchoService", "UnaryEcho")
|
||||
.unwrap();
|
||||
|
||||
let client = GrpcClient {
|
||||
service: EchoServiceServer::new(EchoServiceImpl),
|
||||
};
|
||||
|
||||
let payload = serde_json::json!({ "message": "hello" });
|
||||
|
||||
let res = client
|
||||
.unary(method, payload, vec![])
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(res["message"], "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_streaming() {
|
||||
let registry = DescriptorRegistry::from_bytes(FILE_DESCRIPTOR_SET).unwrap();
|
||||
|
||||
let method = registry
|
||||
.get_method_descriptor("echo.EchoService", "ServerStreamingEcho")
|
||||
.unwrap();
|
||||
|
||||
let client = GrpcClient {
|
||||
service: EchoServiceServer::new(EchoServiceImpl),
|
||||
};
|
||||
|
||||
let payload = serde_json::json!({ "message": "stream" });
|
||||
|
||||
let stream = client
|
||||
.server_streaming(method, payload, vec![])
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let results: Vec<_> = stream.map(|r| r.unwrap()).collect().await;
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0]["message"], "stream - seq 0");
|
||||
assert_eq!(results[1]["message"], "stream - seq 1");
|
||||
assert_eq!(results[2]["message"], "stream - seq 2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_client_streaming() {
|
||||
let registry = DescriptorRegistry::from_bytes(FILE_DESCRIPTOR_SET).unwrap();
|
||||
let method = registry
|
||||
.get_method_descriptor("echo.EchoService", "ClientStreamingEcho")
|
||||
.unwrap();
|
||||
|
||||
let client = GrpcClient {
|
||||
service: EchoServiceServer::new(EchoServiceImpl),
|
||||
};
|
||||
|
||||
let payload = serde_json::json!([
|
||||
{ "message": "A" },
|
||||
{ "message": "B" },
|
||||
{ "message": "C" }
|
||||
]);
|
||||
|
||||
let stream_source = tokio_stream::iter(payload.as_array().unwrap().clone());
|
||||
|
||||
let res = client
|
||||
.client_streaming(method, stream_source, vec![])
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(res["message"], "ABC");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bidirectional_streaming() {
|
||||
let registry = DescriptorRegistry::from_bytes(FILE_DESCRIPTOR_SET).unwrap();
|
||||
let method = registry
|
||||
.get_method_descriptor("echo.EchoService", "BidirectionalEcho")
|
||||
.unwrap();
|
||||
|
||||
let client = GrpcClient {
|
||||
service: EchoServiceServer::new(EchoServiceImpl),
|
||||
};
|
||||
|
||||
let payload = serde_json::json!([
|
||||
{ "message": "Ping" },
|
||||
{ "message": "Pong" }
|
||||
]);
|
||||
|
||||
let stream_source = tokio_stream::iter(payload.as_array().unwrap().clone());
|
||||
|
||||
let response_stream = client
|
||||
.bidirectional_streaming(method, stream_source, vec![])
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let results: Vec<_> = response_stream.map(|r| r.unwrap()).collect().await;
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(results[0]["message"], "echo: Ping");
|
||||
assert_eq!(results[1]["message"], "echo: Pong");
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
pub mod client;
|
||||
mod generated;
|
||||
pub mod registry;
|
||||
|
||||
pub use client::ReflectionClient;
|
||||
pub use registry::DescriptorRegistry;
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
//! # gRPC Server Reflection Client
|
||||
//!
|
||||
//! This module implements a client for `grpc.reflection.v1`. It enables `granc` to function
|
||||
//! without a local descriptor file by asking the server for its own schema.
|
||||
//!
|
||||
//! ## The Resolution Process
|
||||
//!
|
||||
//! 1. **Connect**: Opens a stream to the reflection endpoint.
|
||||
//! 2. **Request Symbol**: Asks for the file containing the requested service (e.g., `my.package.MyService`).
|
||||
//! 3. **Recursive Resolution**:
|
||||
//! - The server returns a `FileDescriptorProto`.
|
||||
//! - The client inspects the imports (dependencies) of that file.
|
||||
//! - It recursively requests any missing dependencies until the full schema tree is built.
|
||||
//! 4. **Build Registry**: Returns a fully populated `DescriptorRegistry`.
|
||||
//!
|
||||
use super::generated::reflection_v1::{
|
||||
ServerReflectionRequest, ServerReflectionResponse,
|
||||
server_reflection_client::ServerReflectionClient, server_reflection_request::MessageRequest,
|
||||
server_reflection_response::MessageResponse,
|
||||
};
|
||||
use crate::core::BoxError;
|
||||
use http_body::Body as HttpBody;
|
||||
use prost::Message;
|
||||
use prost_types::{FileDescriptorProto, FileDescriptorSet};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::transport::{Channel, Endpoint};
|
||||
use tonic::{Streaming, client::GrpcService};
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_test;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ReflectionConnectError {
|
||||
#[error("Invalid URL '{0}': {1}")]
|
||||
InvalidUrl(String, #[source] tonic::transport::Error),
|
||||
#[error("Failed to connect to '{0}': {1}")]
|
||||
ConnectionFailed(String, #[source] tonic::transport::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ReflectionResolveError {
|
||||
#[error(
|
||||
"Failed to start a stream request with the reflection server, reflection might not be supported: '{0}'"
|
||||
)]
|
||||
ServerStreamInitFailed(#[source] tonic::Status),
|
||||
|
||||
#[error("The server stream returned an error status: '{0}'")]
|
||||
ServerStreamFailure(#[source] tonic::Status),
|
||||
|
||||
#[error("Reflection stream closed unexpectedly")]
|
||||
StreamClosed,
|
||||
|
||||
#[error("Internal error: Failed to send request to stream")]
|
||||
SendFailed,
|
||||
|
||||
#[error("Server returned reflection error code {code}: {message}")]
|
||||
ServerError { code: i32, message: String },
|
||||
|
||||
#[error("Protocol error: Received unexpected response type: {0}")]
|
||||
UnexpectedResponseType(String),
|
||||
|
||||
#[error("Failed to decode FileDescriptorProto: {0}")]
|
||||
DecodeError(#[from] prost::DecodeError),
|
||||
}
|
||||
|
||||
/// A generic client for the gRPC Server Reflection Protocol.
|
||||
pub struct ReflectionClient<T = Channel> {
|
||||
client: ServerReflectionClient<T>,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
/// Implementation for the standard network client.
|
||||
impl ReflectionClient<Channel> {
|
||||
pub async fn connect(base_url: String) -> Result<Self, ReflectionConnectError> {
|
||||
let endpoint = Endpoint::new(base_url.clone())
|
||||
.map_err(|e| ReflectionConnectError::InvalidUrl(base_url.clone(), e))?;
|
||||
|
||||
let channel = endpoint
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| ReflectionConnectError::ConnectionFailed(base_url.clone(), e))?;
|
||||
|
||||
let client = ServerReflectionClient::new(channel);
|
||||
|
||||
Ok(Self { client, base_url })
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ReflectionClient<T>
|
||||
where
|
||||
T: GrpcService<tonic::body::Body>,
|
||||
T::Error: Into<BoxError>,
|
||||
T::ResponseBody: HttpBody<Data = tonic::codegen::Bytes> + Send + 'static,
|
||||
<T::ResponseBody as HttpBody>::Error: Into<BoxError> + Send,
|
||||
{
|
||||
/// Asks the reflection service for the file containing the requested symbol (e.g., `my.package.MyService`).
|
||||
///
|
||||
/// **Recursive Resolution**:
|
||||
/// - The server returns a `FileDescriptorProto`.
|
||||
/// - The client inspects the imports (dependencies) of that file.
|
||||
/// - It recursively requests any missing dependencies until the full `FileDescriptorSet` is built.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(fd_set)` - Successful reflection requests execution.
|
||||
/// * `Err(ReflectionResolveError)` - Failed to request file descriptors to the reflection service.
|
||||
pub async fn file_descriptor_set_by_symbol(
|
||||
&mut self,
|
||||
service_name: &str,
|
||||
) -> Result<FileDescriptorSet, ReflectionResolveError> {
|
||||
// Initialize Stream
|
||||
let (tx, rx) = mpsc::channel(100);
|
||||
|
||||
let mut response_stream = self
|
||||
.client
|
||||
.server_reflection_info(ReceiverStream::new(rx))
|
||||
.await
|
||||
.map_err(ReflectionResolveError::ServerStreamInitFailed)?
|
||||
.into_inner();
|
||||
|
||||
// Send Initial Request
|
||||
let req = ServerReflectionRequest {
|
||||
host: self.base_url.clone(),
|
||||
message_request: Some(MessageRequest::FileContainingSymbol(
|
||||
service_name.to_string(),
|
||||
)),
|
||||
};
|
||||
|
||||
tx.send(req)
|
||||
.await
|
||||
.map_err(|_| ReflectionResolveError::SendFailed)?;
|
||||
|
||||
// Fetch all transitive dependencies
|
||||
let file_map = self.collect_descriptors(&mut response_stream, tx).await?;
|
||||
|
||||
// Build Registry directly
|
||||
let fd_set = FileDescriptorSet {
|
||||
file: file_map.into_values().collect(),
|
||||
};
|
||||
|
||||
Ok(fd_set)
|
||||
}
|
||||
|
||||
async fn collect_descriptors(
|
||||
&self,
|
||||
response_stream: &mut Streaming<ServerReflectionResponse>,
|
||||
request_channel: mpsc::Sender<ServerReflectionRequest>,
|
||||
) -> Result<HashMap<String, FileDescriptorProto>, ReflectionResolveError> {
|
||||
let mut inflight = 1;
|
||||
let mut collected_files = HashMap::new();
|
||||
let mut requested = HashSet::new();
|
||||
|
||||
while inflight > 0 {
|
||||
let response = response_stream
|
||||
.message()
|
||||
.await
|
||||
.map_err(ReflectionResolveError::ServerStreamFailure)?
|
||||
.ok_or(ReflectionResolveError::StreamClosed)?;
|
||||
|
||||
inflight -= 1;
|
||||
|
||||
match response.message_response {
|
||||
Some(MessageResponse::FileDescriptorResponse(res)) => {
|
||||
let sent_count = self
|
||||
.process_descriptor_batch(
|
||||
res.file_descriptor_proto,
|
||||
&mut collected_files,
|
||||
&mut requested,
|
||||
&request_channel,
|
||||
)
|
||||
.await?;
|
||||
|
||||
inflight += sent_count;
|
||||
}
|
||||
Some(MessageResponse::ErrorResponse(e)) => {
|
||||
return Err(ReflectionResolveError::ServerError {
|
||||
message: e.error_message,
|
||||
code: e.error_code,
|
||||
});
|
||||
}
|
||||
Some(other) => {
|
||||
return Err(ReflectionResolveError::UnexpectedResponseType(format!(
|
||||
"{:?}",
|
||||
other
|
||||
)));
|
||||
}
|
||||
None => {
|
||||
return Err(ReflectionResolveError::UnexpectedResponseType(
|
||||
"Empty Message".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(collected_files)
|
||||
}
|
||||
|
||||
async fn process_descriptor_batch(
|
||||
&self,
|
||||
raw_protos: Vec<Vec<u8>>,
|
||||
collected_files: &mut HashMap<String, FileDescriptorProto>,
|
||||
requested: &mut HashSet<String>,
|
||||
tx: &mpsc::Sender<ServerReflectionRequest>,
|
||||
) -> Result<usize, ReflectionResolveError> {
|
||||
let mut sent_count = 0;
|
||||
|
||||
for raw in raw_protos {
|
||||
let fd = FileDescriptorProto::decode(raw.as_ref())?;
|
||||
|
||||
if let Some(name) = &fd.name
|
||||
&& !collected_files.contains_key(name)
|
||||
{
|
||||
sent_count += self
|
||||
.queue_dependencies(&fd, collected_files, requested, tx)
|
||||
.await?;
|
||||
|
||||
collected_files.insert(name.clone(), fd);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(sent_count)
|
||||
}
|
||||
|
||||
async fn queue_dependencies(
|
||||
&self,
|
||||
fd: &FileDescriptorProto,
|
||||
collected_files: &HashMap<String, FileDescriptorProto>,
|
||||
requested: &mut HashSet<String>,
|
||||
tx: &mpsc::Sender<ServerReflectionRequest>,
|
||||
) -> Result<usize, ReflectionResolveError> {
|
||||
let mut count = 0;
|
||||
|
||||
for dep in &fd.dependency {
|
||||
if !collected_files.contains_key(dep) && requested.insert(dep.clone()) {
|
||||
let req = ServerReflectionRequest {
|
||||
host: self.base_url.clone(),
|
||||
message_request: Some(MessageRequest::FileByFilename(dep.clone())),
|
||||
};
|
||||
|
||||
tx.send(req)
|
||||
.await
|
||||
.map_err(|_| ReflectionResolveError::SendFailed)?;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
use crate::core::reflection::{
|
||||
DescriptorRegistry,
|
||||
client::{
|
||||
ReflectionClient, ReflectionResolveError, integration_test::dummy_service::DummyEchoService,
|
||||
},
|
||||
generated::reflection_v1::server_reflection_client::ServerReflectionClient,
|
||||
};
|
||||
use echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET};
|
||||
use tonic::Code;
|
||||
use tonic_reflection::server::v1::ServerReflectionServer;
|
||||
|
||||
mod dummy_service;
|
||||
|
||||
fn setup_reflection_client()
|
||||
-> ReflectionClient<ServerReflectionServer<impl tonic_reflection::server::v1::ServerReflection>> {
|
||||
// Configure the Reflection Service using the descriptor set from echo-service
|
||||
let reflection_service = tonic_reflection::server::Builder::configure()
|
||||
.register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
|
||||
.build_v1()
|
||||
.expect("Failed to setup Reflection Service");
|
||||
|
||||
ReflectionClient {
|
||||
client: ServerReflectionClient::new(reflection_service),
|
||||
base_url: "http://localhost".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_client_fetches_unary_echo() {
|
||||
let mut client = setup_reflection_client();
|
||||
|
||||
let fd_set = client
|
||||
.file_descriptor_set_by_symbol("echo.EchoService")
|
||||
.await
|
||||
.expect("Failed to fetch file descriptor set by symbol");
|
||||
|
||||
let registry = DescriptorRegistry::from_file_descriptor_set(fd_set)
|
||||
.expect("Failed to build descriptor registry");
|
||||
|
||||
let method = registry
|
||||
.get_method_descriptor("echo.EchoService", "UnaryEcho")
|
||||
.expect("Method UnaryEcho not found");
|
||||
|
||||
// Assert Types
|
||||
assert_eq!(method.input().name(), "EchoRequest");
|
||||
assert_eq!(method.output().name(), "EchoResponse");
|
||||
|
||||
// Assert Streaming Properties (Unary = No Streaming)
|
||||
assert!(
|
||||
!method.is_client_streaming(),
|
||||
"Unary should not be client streaming"
|
||||
);
|
||||
assert!(
|
||||
!method.is_server_streaming(),
|
||||
"Unary should not be server streaming"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_client_fetches_server_streaming_echo() {
|
||||
let mut client = setup_reflection_client();
|
||||
|
||||
let fd_set = client
|
||||
.file_descriptor_set_by_symbol("echo.EchoService")
|
||||
.await
|
||||
.expect("Failed to fetch file descriptor set by symbol");
|
||||
|
||||
let registry = DescriptorRegistry::from_file_descriptor_set(fd_set)
|
||||
.expect("Failed to build descriptor registry");
|
||||
|
||||
let method = registry
|
||||
.get_method_descriptor("echo.EchoService", "ServerStreamingEcho")
|
||||
.expect("Method ServerStreamingEcho not found");
|
||||
|
||||
// Assert Types
|
||||
assert_eq!(method.input().name(), "EchoRequest");
|
||||
assert_eq!(method.output().name(), "EchoResponse");
|
||||
|
||||
// Assert Streaming Properties (Server Streaming only)
|
||||
assert!(
|
||||
!method.is_client_streaming(),
|
||||
"ServerStreaming should not be client streaming"
|
||||
);
|
||||
assert!(
|
||||
method.is_server_streaming(),
|
||||
"ServerStreaming MUST be server streaming"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_client_fetches_client_streaming_echo() {
|
||||
let mut client = setup_reflection_client();
|
||||
|
||||
let fd_set = client
|
||||
.file_descriptor_set_by_symbol("echo.EchoService")
|
||||
.await
|
||||
.expect("Failed to fetch file descriptor set by symbol");
|
||||
|
||||
let registry = DescriptorRegistry::from_file_descriptor_set(fd_set)
|
||||
.expect("Failed to build descriptor registry");
|
||||
|
||||
let method = registry
|
||||
.get_method_descriptor("echo.EchoService", "ClientStreamingEcho")
|
||||
.expect("Method ClientStreamingEcho not found");
|
||||
|
||||
// Assert Types
|
||||
assert_eq!(method.input().name(), "EchoRequest");
|
||||
assert_eq!(method.output().name(), "EchoResponse");
|
||||
|
||||
// Assert Streaming Properties (Client Streaming only)
|
||||
assert!(
|
||||
method.is_client_streaming(),
|
||||
"ClientStreaming MUST be client streaming"
|
||||
);
|
||||
assert!(
|
||||
!method.is_server_streaming(),
|
||||
"ClientStreaming should not be server streaming"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_client_fetches_bidirectional_echo() {
|
||||
let mut client = setup_reflection_client();
|
||||
|
||||
let fd_set = client
|
||||
.file_descriptor_set_by_symbol("echo.EchoService")
|
||||
.await
|
||||
.expect("Failed to fetch file descriptor set by symbol");
|
||||
|
||||
let registry = DescriptorRegistry::from_file_descriptor_set(fd_set)
|
||||
.expect("Failed to build descriptor registry");
|
||||
|
||||
let method = registry
|
||||
.get_method_descriptor("echo.EchoService", "BidirectionalEcho")
|
||||
.expect("Method BidirectionalEcho not found");
|
||||
|
||||
assert_eq!(method.input().name(), "EchoRequest");
|
||||
assert_eq!(method.output().name(), "EchoResponse");
|
||||
|
||||
assert!(
|
||||
method.is_client_streaming(),
|
||||
"Bidirectional MUST be client streaming"
|
||||
);
|
||||
assert!(
|
||||
method.is_server_streaming(),
|
||||
"Bidirectional MUST be server streaming"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_service_not_found_error() {
|
||||
let mut client = setup_reflection_client();
|
||||
|
||||
let result: Result<_, _> = client
|
||||
.file_descriptor_set_by_symbol("non.existent.Service")
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(crate::core::reflection::client::ReflectionResolveError::ServerStreamFailure(status)) if status.code() == Code::NotFound
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_does_not_support_reflection() {
|
||||
// Create a server that ONLY hosts the EchoService.
|
||||
// This server does NOT have the Reflection service registered.
|
||||
let server = EchoServiceServer::new(DummyEchoService);
|
||||
|
||||
let mut client = ReflectionClient {
|
||||
client: ServerReflectionClient::new(server),
|
||||
base_url: "http://localhost".to_string(),
|
||||
};
|
||||
|
||||
// The client will attempt to call `/grpc.reflection.v1.ServerReflection/ServerReflectionInfo` on this service.
|
||||
let result = client
|
||||
.file_descriptor_set_by_symbol("echo.EchoService")
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(ReflectionResolveError::ServerStreamInitFailed(status)) => {
|
||||
assert_eq!(
|
||||
status.code(),
|
||||
tonic::Code::Unimplemented,
|
||||
"Expected UNIMPLEMENTED status (service not found), but got: {:?}",
|
||||
status
|
||||
);
|
||||
}
|
||||
Err(e) => panic!("Expected StreamInitFailed(Unimplemented), got: {:?}", e),
|
||||
Ok(_) => panic!("Expected error, but got successful registry"),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
//! # Descriptor Registry
|
||||
//!
|
||||
//! This module manages the `prost_reflect::DescriptorPool`.
|
||||
//!
|
||||
//! The registry can be populated in two ways:
|
||||
//!
|
||||
//! 1. **From File**: Loading a binary `.bin` or `.pb` file (usually generated by `protoc`).
|
||||
//! 2. **From Memory**: Constructed dynamically after fetching schemas via Server Reflection.
|
||||
use prost_reflect::{DescriptorPool, MethodDescriptor};
|
||||
use prost_types::FileDescriptorSet;
|
||||
use std::path::Path;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DescriptorError {
|
||||
#[error("Failed to read descriptor file: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Failed to decode descriptor set: {0}")]
|
||||
Decode(#[from] prost_reflect::DescriptorError),
|
||||
#[error("Service '{0}' not found")]
|
||||
ServiceNotFound(String),
|
||||
#[error("Method '{0}' not found")]
|
||||
MethodNotFound(String),
|
||||
}
|
||||
|
||||
/// A registry that holds loaded Protobuf definitions and allows looking up
|
||||
/// services and methods by name.
|
||||
#[derive(Debug)]
|
||||
pub struct DescriptorRegistry {
|
||||
pool: DescriptorPool,
|
||||
}
|
||||
|
||||
impl DescriptorRegistry {
|
||||
/// Decodes a FileDescriptorSet directly from a byte slice.
|
||||
/// Useful for tests or embedded descriptors.
|
||||
#[cfg(test)]
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, DescriptorError> {
|
||||
let pool = DescriptorPool::decode(bytes)?;
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
/// Creates a registry from a `FileDescriptorSet` (e.g., from Server Reflection).
|
||||
pub fn from_file_descriptor_set(set: FileDescriptorSet) -> Result<Self, DescriptorError> {
|
||||
let pool = DescriptorPool::from_file_descriptor_set(set)?;
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
/// Creates a registry by reading a `.bin` or `.pb` file from disk.
|
||||
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, DescriptorError> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let pool = DescriptorPool::decode(bytes.as_slice())?;
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
/// Resolves a service and method (e.g., "my.package.MyService", "MyMethod") into a MethodDescriptor.
|
||||
pub fn get_method_descriptor(
|
||||
&self,
|
||||
service_name: &str,
|
||||
method_name: &str,
|
||||
) -> Result<MethodDescriptor, DescriptorError> {
|
||||
let service = self
|
||||
.pool
|
||||
.get_service_by_name(service_name)
|
||||
.ok_or_else(|| DescriptorError::ServiceNotFound(service_name.to_string()))?;
|
||||
|
||||
service
|
||||
.methods()
|
||||
.find(|m| m.name() == method_name)
|
||||
.ok_or_else(|| DescriptorError::MethodNotFound(method_name.to_string()))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +1,36 @@
|
|||
#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))]
|
||||
//! # Granc CLI Entry Point
|
||||
//!
|
||||
//! The main executable for the Granc tool. This file drives the application lifecycle:
|
||||
//!
|
||||
//! 1. **Initialization**: Parses command-line arguments using [`cli::Cli`].
|
||||
//! 2. **Connection**: Establishes a TCP connection to the target server via `granc_core`.
|
||||
//! 3. **Execution**: Delegates the request processing to the `GrancClient`.
|
||||
//! 4. **Presentation**: Formats and prints the resulting JSON or error status to standard output/error.
|
||||
|
||||
mod cli;
|
||||
mod core;
|
||||
|
||||
use clap::Parser;
|
||||
use cli::Cli;
|
||||
use granc_core::client::{DynamicRequest, DynamicResponse, GrancClient};
|
||||
use std::process;
|
||||
|
||||
use crate::core::Output;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args = Cli::parse();
|
||||
|
||||
match core::run(core::Input::from(args)).await {
|
||||
Ok(Output::Unary(Ok(value))) => print_json(&value),
|
||||
Ok(Output::Unary(Err(status))) => print_status(&status),
|
||||
Ok(Output::Streaming(Ok(values))) => print_stream(&values),
|
||||
Ok(Output::Streaming(Err(status))) => print_status(&status),
|
||||
let mut client = match GrancClient::connect(&args.url).await {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
eprintln!("Error: {err}");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match client.dynamic(DynamicRequest::from(args)).await {
|
||||
Ok(DynamicResponse::Unary(Ok(value))) => print_json(&value),
|
||||
Ok(DynamicResponse::Unary(Err(status))) => print_status(&status),
|
||||
Ok(DynamicResponse::Streaming(Ok(values))) => print_stream(&values),
|
||||
Ok(DynamicResponse::Streaming(Err(status))) => print_status(&status),
|
||||
Err(err) => {
|
||||
eprintln!("Error: {err}");
|
||||
process::exit(1);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[workspace]
|
||||
# set the path of all the crates to the changelog to the root of the repository
|
||||
changelog_path = "./granc/CHANGELOG.md"
|
||||
changelog_path = "CHANGELOG.md"
|
||||
pr_draft = true
|
||||
pr_labels = ["release"]
|
||||
pr_branch_prefix = "release-"
|
||||
|
|
|
|||
Loading…
Reference in a new issue