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:
Víctor Martínez 2026-01-22 15:07:10 +01:00 committed by GitHub
parent 09478c6b19
commit 7bc2e4c0a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1285 additions and 1184 deletions

View file

@ -1 +0,0 @@
granc/CHANGELOG.md

47
CHANGELOG.md Normal file
View 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
View file

@ -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"

View file

@ -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"

View file

@ -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",
]

View file

@ -1 +0,0 @@
granc/README.md

172
README.md Normal file
View file

@ -0,0 +1,172 @@
# Granc 🦀
[![granc on crates.io](https://img.shields.io/crates/v/granc)](https://crates.io/crates/granc)
[![License](https://img.shields.io/crates/l/granc.svg)](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.

View file

@ -1,6 +1,6 @@
[package]
name = "echo-service"
edition = "2024"
edition = { workspace = true }
publish = false
[dependencies]

46
granc-core/Cargo.toml Normal file
View 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
View file

@ -0,0 +1,126 @@
# Granc Core
[![Crates.io](https://img.shields.io/crates/v/granc_core.svg)](https://crates.io/crates/granc_core)
[![Documentation](https://docs.rs/granc_core/badge.svg)](https://docs.rs/granc_core)
[![License](https://img.shields.io/crates/l/granc_core.svg)](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
View 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
View 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;

View file

@ -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
View 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>;

View 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;

View 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)
}

View file

@ -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};

View file

@ -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;

View 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"),
};
}

View 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"),
}
}

View file

@ -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.

View file

@ -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 }

View file

@ -1,171 +0,0 @@
# Granc 🦀
[![granc on crates.io](https://img.shields.io/crates/v/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.

View file

@ -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'",)

View file

@ -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(),
)),
}
}

View file

@ -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");
}

View file

@ -1,6 +0,0 @@
pub mod client;
mod generated;
pub mod registry;
pub use client::ReflectionClient;
pub use registry::DescriptorRegistry;

View file

@ -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)
}
}

View file

@ -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"),
}
}

View file

@ -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()))
}
}

View file

@ -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);

View file

@ -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-"