[fix] A URL should not be required for list and describe commands (#35)

solves #34
This commit is contained in:
Víctor Martínez 2026-01-28 14:09:41 +01:00 committed by GitHub
parent 8ce153e271
commit 9990e94c8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1121 additions and 762 deletions

22
Cargo.lock generated
View file

@ -340,7 +340,7 @@ version = "0.6.0"
dependencies = [
"clap",
"colored",
"granc_core 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)",
"granc_core",
"serde_json",
"tokio",
]
@ -364,26 +364,6 @@ dependencies = [
"tonic-reflection",
]
[[package]]
name = "granc_core"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4375e91b7a00f76c4248eeaa67ce5fe0f62087c6c3139f2b6588c06982fce1cd"
dependencies = [
"futures-util",
"http",
"http-body",
"prost",
"prost-reflect",
"prost-types",
"serde_json",
"thiserror",
"tokio",
"tokio-stream",
"tonic",
"tonic-reflection",
]
[[package]]
name = "h2"
version = "0.4.13"

View file

@ -13,7 +13,6 @@ It allows you to make gRPC calls to any server using simple JSON payloads, witho
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`.
@ -30,6 +29,7 @@ It is heavily inspired by tools like `grpcurl` but built to leverage the safety
```bash
cargo install --locked granc
```
## 🛠️ Prerequisites
@ -50,6 +50,7 @@ protoc \
--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.
@ -59,16 +60,10 @@ protoc \
**Syntax:**
```bash
granc <URL> [OPTIONS] <COMMAND> [ARGS]
granc <COMMAND> [ARGS]
```
### Global Arguments
| Argument | Description | Required |
| --- | --- | --- |
| `<URL>` | Server address (e.g., `http://[::1]:50051`). Must be the first argument. | **Yes** |
| `--file-descriptor-set` | Path to the binary FileDescriptorSet (`.bin`). If omitted, Granc attempts to use Server Reflection. | No |
### Commands
#### 1. `call` (Make Requests)
@ -76,59 +71,97 @@ granc <URL> [OPTIONS] <COMMAND> [ARGS]
Performs a gRPC call using a JSON body.
```bash
granc http://localhost:50051 [OPTIONS] call <ENDPOINT> --body <JSON> [ARGS]
granc call <ENDPOINT> --uri <URI> --body <JSON> [OPTIONS]
```
| Argument/Flag | Description | Required |
| --- | --- | --- |
| `<ENDPOINT>` | Fully qualified method name (e.g., `my.package.Service/Method`). | **Yes** |
| `--body` | The request body in JSON format. Object `{}` for unary, Array `[]` for streaming. | **Yes** |
| `--header`, `-H` | Custom header `key:value`. Can be used multiple times. | No |
| Argument/Flag | Short | Description | Required |
| --- | --- | --- | --- |
| `<ENDPOINT>` | | Fully qualified method name (e.g., `my.package.Service/Method`). | **Yes** |
| `--uri` | `-u` | Server address (e.g., `http://[::1]:50051`). | **Yes** |
| `--body` | `-b` | The request body in JSON format. Object `{}` for unary, Array `[]` for streaming. | **Yes** |
| `--header` | `-H` | Custom header `key:value`. Can be used multiple times. | No |
| `--file-descriptor-set` | `-f` | Path to a local `.bin` descriptor file to use instead of reflection. | No |
**Example using Server Reflection:**
```bash
granc http://localhost:50051 call helloworld.Greeter/SayHello --body '{"name": "Ferris"}'
granc call helloworld.Greeter/SayHello --uri http://localhost:50051 --body '{"name": "Ferris"}'
```
```json
{
"message": "Hello Ferris"
}
```
**Example using a Local Descriptor File:**
```bash
granc http://localhost:50051 --file-descriptor-set ./descriptors.bin call helloworld.Greeter/SayHello --body '{"name": "Ferris"}'
granc call helloworld.Greeter/SayHello \
--uri http://localhost:50051 \
--file-descriptor-set ./descriptors.bin \
--body '{"name": "Ferris"}'
```
#### 2. `list` (Service Discovery)
Lists all services exposed by the server (via reflection) or contained in the provided descriptor file.
Lists all services exposed by the server (via reflection) or contained in the provided descriptor file. You must provide **either** a URI or a file descriptor set.
```bash
granc http://localhost:50051 list
granc list [OPTIONS]
```
| Flag | Short | Description |
| --- | --- | --- |
| `--uri` | `-u` | Use Server Reflection to list available services. |
| `--file-descriptor-set` | `-f` | Use a local file to list contained services (offline). |
**Listing services via Reflection:**
```bash
granc list --uri http://localhost:50051
```
```
Available Services:
- grpc.reflection.v1.ServerReflection
- helloworld.Greeter
```
**Listing services from a file:**
**Listing services from a file (Offline):**
```bash
granc http://localhost:50051 --file-descriptor-set ./descriptors.bin list
granc list --file-descriptor-set ./descriptors.bin
```
#### 3. `describe` (Introspection)
Inspects a specific symbol (Service, Message, or Enum) and prints its Protobuf definition in a colored, human-readable format.
Inspects a specific symbol (Service, Message, or Enum) and prints its Protobuf definition in a colored, human-readable format. You must provide **either** a URI or a file descriptor set.
```bash
granc http://localhost:50051 describe helloworld.Greeter
granc describe <SYMBOL> [OPTIONS]
```
| Argument/Flag | Short | Description |
| --- | --- | --- |
| `<SYMBOL>` | | Fully qualified name of the Service, Message, or Enum. |
| `--uri` | `-u` | Use Server Reflection to resolve the symbol. |
| `--file-descriptor-set` | `-f` | Use a local file to resolve the symbol (offline). |
**Describing a Service via Reflection:**
```bash
granc describe helloworld.Greeter --uri http://localhost:50051
```
```proto
@ -136,12 +169,14 @@ service Greeter {
rpc SayHello(helloworld.HelloRequest) returns (helloworld.HelloReply);
rpc StreamHello(stream helloworld.HelloRequest) returns (stream helloworld.HelloReply);
}
```
**Describing a Message using a Local File:**
```bash
granc http://localhost:50051 --file-descriptor-set ./descriptors.bin describe helloworld.HelloRequest
granc describe helloworld.HelloRequest --file-descriptor-set ./descriptors.bin
```
```proto
@ -150,12 +185,14 @@ message HelloRequest {
int32 age = 2;
repeated string tags = 3;
}
```
**Describing an Enum:**
```bash
granc http://localhost:50051 describe my.package.Status
granc describe my.package.Status --uri http://localhost:50051
```
```proto
@ -164,6 +201,7 @@ enum Status {
ACTIVE = 1;
INACTIVE = 2;
}
```
## 🔮 Roadmap
@ -178,7 +216,7 @@ The core logic of Granc is decoupled into a separate library crate, **`granc-cor
If you want to build your own tools using the dynamic gRPC engine (e.g., for custom integration testing, proxies, or automation tools), you can depend on `granc-core` directly.
* **Documentation & Usage**: See the **[`granc-core` README](https://www.google.com/search?q=./granc-core/README.md)** for examples on how to use the `GrancClient` programmatically.
* **Documentation & Usage**: See the **[`granc-core` README](./granc-core/README.md)** for examples on how to use the `GrancClient` programmatically.
* **Crate**: [`granc-core`](https://crates.io/crates/granc_core)
## ⚠️ Common Errors

View file

@ -10,13 +10,15 @@ Instead of strictly typed Rust structs, this library bridges standard `serde_jso
## 🚀 High-Level Usage
The primary entry point is the [`GrancClient`]. It acts as an orchestrator that connects to a gRPC server and provides methods for both executing requests and inspecting the server's schema.
The primary entry point is the [`GrancClient`]. It uses a **Typestate Pattern** to ensure safety and correctness regarding how the Protobuf schema is resolved. There are three distinct states:
To ensure safety and correctness, `GrancClient` uses a **Typestate Pattern**. It starts in a state that relies on Server Reflection, but can transition to a state that uses a local `FileDescriptorSet`.
1. **[`Online`]**: Connected to a server, uses Server Reflection (Async introspection).
2. **[`OnlineWithoutReflection`]**: Connected to a server, uses a local `FileDescriptorSet` (Sync introspection).
3. **[`Offline`]**: Disconnected, uses a local `FileDescriptorSet` (Sync introspection).
### 1. Using Server Reflection (Default)
### 1. Online (Server Reflection)
By default, when you connect, the client is ready to use the server's reflection service to resolve methods and types dynamically.
This is the default state when you connect. The client queries the server's reflection endpoint to dynamically discover services and message formats.
```rust
use granc_core::client::{GrancClient, DynamicRequest, DynamicResponse};
@ -24,91 +26,81 @@ use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Connect (starts in Reflection mode)
// 1. Connect (Starts in 'Online' state)
let mut client = GrancClient::connect("http://localhost:50051").await?;
// 2. Introspection (Async via Reflection)
let services = client.list_services().await?;
println!("Server services: {:?}", services);
// 3. Dynamic Call
let request = DynamicRequest {
service: "helloworld.Greeter".to_string(),
method: "SayHello".to_string(),
body: json!({ "name": "World" }),
body: json!({ "name": "Ferris" }),
headers: vec![],
};
// Execute (Schema is fetched automatically via reflection)
// Schema is fetched automatically from the server
let response = client.dynamic(request).await?;
match response {
DynamicResponse::Unary(Ok(value)) => println!("Response: {}", value),
DynamicResponse::Unary(Err(status)) => eprintln!("gRPC Error: {:?}", status),
_ => {}
}
println!("{:?}", response);
Ok(())
}
```
### 2. Using a Local Descriptor File
### 2. OnlineWithoutReflection (Local Schema)
If you have a `.bin` file generated by `protoc`, you can load it into the client. This transforms the client's state, disabling reflection and forcing it to look up schemas in the provided file.
Use this state if you are connecting to a server that does not support reflection, or if you want to enforce a specific schema version from a local file.
```rust
use granc_core::client::GrancClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Connect (starts in Reflection mode)
let client = GrancClient::connect("http://localhost:50051").await?;
// Read the descriptor file
let descriptor_bytes = std::fs::read("descriptor.bin")?;
// Transition to File Descriptor mode
// Transition state: Online -> OnlineWithoutReflection
let mut client = client.with_file_descriptor(descriptor_bytes)?;
// Now use this client for requests. It will NOT query the server for schema.
// Introspection is now SYNCHRONOUS (in-memory)
let services = client.list_services();
println!("Services in file: {:?}", services);
println!("Local services: {:?}", services);
// Dynamic calls use the local schema to encode/decode
// client.dynamic(req).await?;
Ok(())
}
```
### 3. Schema Introspection
### 3. Offline (Introspection Only)
Both client states expose methods to inspect the available schema, but their APIs differ slightly because reflection requires network calls (async) while file lookups are in-memory (sync).
#### Using Server Reflection (Async)
This state is useful for building tools that need to inspect `.bin` descriptor files without establishing a network connection.
```rust
// List available services (requires network call)
let services = client.list_services().await?;
use granc_core::client::GrancClient;
// Get a specific descriptor (requires network call)
// Returns Result<Descriptor, Error>
let descriptor = client.get_descriptor_by_symbol("helloworld.Greeter").await?;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let descriptor_bytes = std::fs::read("descriptor.bin")?;
match descriptor {
Descriptor::ServiceDescriptor(svc) => println!("Service: {}", svc.name()),
Descriptor::MessageDescriptor(msg) => println!("Message: {}", msg.name()),
Descriptor::EnumDescriptor(enm) => println!("Enum: {}", enm.name()),
// Create directly in 'Offline' state
let client = GrancClient::offline(descriptor_bytes)?;
// Sync introspection methods
let services = client.list_services();
if let Some(descriptor) = client.get_descriptor_by_symbol("helloworld.Greeter") {
println!("Found service: {:?}", descriptor);
}
// Note: client.dynamic() is NOT available in this state.
Ok(())
}
```
#### Using Local File (Sync)
```rust
// List available services (immediate, can't fail)
let services = client_fd.list_services();
// Get a specific descriptor (immediate)
// Returns Option<Descriptor>
if let Some(descriptor) = client_fd.get_descriptor_by_symbol("helloworld.Greeter") {
println!("Found symbol: {:?}", descriptor);
} else {
println!("Symbol not found in file");
}
```
## 🛠️ Internal Components
@ -117,39 +109,15 @@ We expose the internal building blocks of `granc` for developers who need more g
### 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?;
```
Standard `tonic` clients are strongly typed. `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.
### 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. It also supports listing available services.
A robust client for `grpc.reflection.v1`. It automatically handles transitive dependency resolution, recursively fetching all imported files to build a complete, self-contained `FileDescriptorSet`.
## ⚖️ License

View file

@ -3,14 +3,15 @@
//! This module implements the high-level logic for executing dynamic gRPC requests.
//!
//! The [`GrancClient`] uses a **Typestate Pattern** to ensure safety and correctness regarding
//! how the Protobuf schema is resolved. It has two possible states:
//! how the Protobuf schema is resolved. It has three possible states:
//!
//! 1. **[`WithServerReflection`]**: The default state. The client is connected
//! to a server and uses the gRPC Server Reflection Protocol (`grpc.reflection.v1`) to discover
//! services and fetch schemas on the fly.
//! 2. **[`WithFileDescriptor`]**: The client has been provided with a specific
//! binary `FileDescriptorSet` (e.g., loaded from a `.bin` file). In this state, reflection is
//! disabled, and all lookups are performed against the provided file.
//! 1. **[`Online`]**: The default state when connecting. The client uses the gRPC
//! Server Reflection Protocol (`grpc.reflection.v1`) to discover services.
//! 2. **[`OnlineWithoutReflection`]**: The client is connected to a server but uses a local
//! binary `FileDescriptorSet` for schema lookups.
//! 3. **[`Offline`]**: The client is **not connected** to any server. It holds a
//! local `FileDescriptorSet` and can only be used for introspection (Listing services, describing symbols),
//! but cannot perform gRPC calls.
//!
//! ## Example: State Transition
//!
@ -18,109 +19,89 @@
//! use granc_core::client::GrancClient;
//!
//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
//! // Connect (starts in Reflection state)
//! let mut client_reflection = GrancClient::connect("http://localhost:50051").await?;
//! // 1. Online State (Reflection)
//! let mut client = GrancClient::connect("http://localhost:50051").await?;
//!
//! // The API here is async
//! let services = client_reflection.list_services().await?;
//!
//! // 2Transition to File Descriptor state
//! // 2. Transition to OnlineWithoutReflection (Connected + Local Schema)
//! let bytes = std::fs::read("descriptor.bin")?;
//! let mut client_fd = client_reflection.with_file_descriptor(bytes)?;
//! let mut client_static = client.with_file_descriptor(bytes)?;
//!
//! // Now operations use the local file and are sync
//! let services = client_fd.list_services();
//! // 3. Offline State (Disconnected + Local Schema)
//! let bytes = std::fs::read("descriptor.bin")?;
//! let mut client_offline = GrancClient::offline(bytes)?;
//! # Ok(())
//! # }
//! ```
pub mod with_file_descriptor;
pub mod with_server_reflection;
pub mod offline;
pub mod online;
pub mod online_without_reflection;
mod types;
pub use types::*;
use crate::{grpc::client::GrpcClient, reflection::client::ReflectionClient};
use prost_reflect::{DescriptorPool, EnumDescriptor, MessageDescriptor, ServiceDescriptor};
use prost_reflect::DescriptorPool;
use std::fmt::Debug;
use tonic::transport::Channel;
/// The main client for interacting with gRPC servers dynamically.
///
/// The generic parameter `T` represents the current state of the client, determining
/// its capabilities and how it resolves Protobuf schemas.
/// The generic parameter `T` represents the current state of the client.
#[derive(Clone, Debug)]
pub struct GrancClient<T> {
state: T,
}
/// The state for a client that uses a local `DescriptorPool` for schema resolution.
#[derive(Debug, Clone)]
pub struct WithFileDescriptor<S = Channel> {
grpc_client: GrpcClient<S>,
pool: DescriptorPool,
impl<T> GrancClient<T> {
pub(crate) fn new(state: T) -> Self {
Self { state }
}
}
/// The state for a client that uses Server Reflection for schema resolution.
/// State: Connected to server, Schema resolved from Server Reflection.
#[derive(Debug, Clone)]
pub struct WithServerReflection<S = Channel> {
pub struct Online<S = Channel> {
reflection_client: ReflectionClient<S>,
grpc_client: GrpcClient<S>,
}
/// A request object encapsulating all necessary information to perform a dynamic gRPC call.
/// State: Connected to server, Schema resolved from local FileDescriptor.
#[derive(Debug, Clone)]
pub struct DynamicRequest {
/// The JSON body of the request.
/// - For Unary/ServerStreaming: An Object `{}`.
/// - For ClientStreaming/Bidirectional: An Array of Objects `[{}]`.
pub body: serde_json::Value,
/// Custom gRPC metadata (headers) to attach to the request.
pub headers: Vec<(String, String)>,
/// The fully qualified name of the service (e.g., `my.package.Service`).
pub service: String,
/// The name of the method to call (e.g., `SayHello`).
pub method: String,
pub struct OnlineWithoutReflection<S = Channel> {
grpc_client: GrpcClient<S>,
pool: DescriptorPool,
}
/// The result of a dynamic gRPC call.
impl<S> OnlineWithoutReflection<S> {
pub(crate) fn new(grpc_client: GrpcClient<S>, pool: DescriptorPool) -> Self {
Self { pool, grpc_client }
}
}
/// State: Disconnected, Schema resolved from local FileDescriptor.
#[derive(Debug, Clone)]
pub enum DynamicResponse {
/// A single response message (for Unary and Client Streaming calls).
Unary(Result<serde_json::Value, tonic::Status>),
/// A stream of response messages (for Server Streaming and Bidirectional calls).
Streaming(Result<Vec<Result<serde_json::Value, tonic::Status>>, tonic::Status>),
pub struct Offline {
pool: DescriptorPool,
}
/// A generic wrapper for different types of Protobuf descriptors.
///
/// This enum allows the client to return a single type when resolving symbols,
/// regardless of whether the symbol points to a Service, a Message, or an Enum.
#[derive(Debug, Clone)]
pub enum Descriptor {
MessageDescriptor(MessageDescriptor),
ServiceDescriptor(ServiceDescriptor),
EnumDescriptor(EnumDescriptor),
}
impl Descriptor {
/// Returns the inner [`MessageDescriptor`] if this variant is `MessageDescriptor`.
pub fn message_descriptor(&self) -> Option<&MessageDescriptor> {
match self {
Descriptor::MessageDescriptor(message_descriptor) => Some(message_descriptor),
_ => None,
}
}
/// Returns the inner [`ServiceDescriptor`] if this variant is `ServiceDescriptor`.
pub fn service_descriptor(&self) -> Option<&ServiceDescriptor> {
match self {
Descriptor::ServiceDescriptor(service_descriptor) => Some(service_descriptor),
_ => None,
}
}
/// Returns the inner [`EnumDescriptor`] if this variant is `EnumDescriptor`.
pub fn enum_descriptor(&self) -> Option<&EnumDescriptor> {
match self {
Descriptor::EnumDescriptor(enum_descriptor) => Some(enum_descriptor),
_ => None,
}
impl Offline {
pub(crate) fn new(pool: DescriptorPool) -> Self {
Self { pool }
}
}
pub trait OfflineReflectionState {
fn descriptor_pool(&self) -> &DescriptorPool;
}
impl OfflineReflectionState for Offline {
fn descriptor_pool(&self) -> &DescriptorPool {
&self.pool
}
}
impl<S> OfflineReflectionState for OnlineWithoutReflection<S> {
fn descriptor_pool(&self) -> &DescriptorPool {
&self.pool
}
}

View file

@ -0,0 +1,73 @@
//! # Client State: Offline
//!
//! This module defines the `GrancClient` behavior when it is using a local, in-memory
//! `DescriptorPool` but is **not connected** to any gRPC server.
//!
//! In this state, the client is strictly limited to introspection tasks.
use super::{GrancClient, Offline};
use crate::client::{OfflineReflectionState, types::Descriptor};
use prost_reflect::{DescriptorError, DescriptorPool};
impl GrancClient<Offline> {
/// Creates a new `GrancClient` in the Offline state using a raw byte buffer
/// containing a `FileDescriptorSet`.
///
/// This client starts in a **disconnected** state. It can be used to inspect the
/// provided schema but cannot make network requests.
///
/// # Arguments
///
/// * `file_descriptor` - A vector of bytes containing the encoded `FileDescriptorSet`.
///
/// # Returns
///
/// * `Ok(GrancClient<Offline>)` - The initialized offline client.
/// * `Err(DescriptorError)` - If the bytes are not a valid descriptor set.
pub fn offline(file_descriptor: Vec<u8>) -> Result<Self, DescriptorError> {
let pool = DescriptorPool::decode(file_descriptor.as_slice())?;
Ok(GrancClient::new(Offline::new(pool)))
}
}
impl<T> GrancClient<T>
where
T: OfflineReflectionState,
{
/// Lists all services defined in the local `DescriptorPool`.
///
/// # Returns
///
/// A list of fully qualified service names (e.g. `helloworld.Greeter`).
pub fn list_services(&self) -> Vec<String> {
self.state
.descriptor_pool()
.services()
.map(|s| s.full_name().to_string())
.collect()
}
/// Looks up a specific symbol in the local `DescriptorPool`.
///
/// # Arguments
///
/// * `symbol` - The fully qualified name (Service, Message, or Enum).
///
/// # Returns
///
/// * `Some(Descriptor)` - The resolved descriptor if found.
/// * `None` - If the symbol does not exist in the pool.
pub fn get_descriptor_by_symbol(&self, symbol: &str) -> Option<Descriptor> {
let pool = self.state.descriptor_pool();
if let Some(descriptor) = pool.get_service_by_name(symbol) {
return Some(Descriptor::ServiceDescriptor(descriptor));
}
if let Some(descriptor) = pool.get_message_by_name(symbol) {
return Some(Descriptor::MessageDescriptor(descriptor));
}
if let Some(descriptor) = pool.get_enum_by_name(symbol) {
return Some(Descriptor::EnumDescriptor(descriptor));
}
None
}
}

View file

@ -1,45 +1,45 @@
//! # Client State: Server Reflection
//! # Client State: Online (With Server Reflection)
//!
//! This module defines the `GrancClient` behavior when it is using the server's reflection service
//! to resolve schemas.
//! This module defines the `GrancClient` behavior when it is connected to a server
//! and using Server Reflection for schema resolution.
use super::{
Descriptor, DynamicRequest, DynamicResponse, GrancClient, WithFileDescriptor,
WithServerReflection,
Descriptor, DynamicRequest, DynamicResponse, GrancClient, Online, OnlineWithoutReflection,
};
use crate::{
BoxError,
client::Offline,
grpc::client::GrpcClient,
reflection::client::{ReflectionClient, ReflectionResolveError},
};
use http_body::Body as HttpBody;
use prost_reflect::DescriptorError;
use prost_reflect::DescriptorPool;
use prost_reflect::{DescriptorError, DescriptorPool};
use std::fmt::Debug;
use tonic::{
Code,
transport::{Channel, Endpoint},
};
/// Errors that can occur when connecting to a gRPC server.
#[derive(Debug, thiserror::Error)]
pub enum ClientConnectError {
#[error("Invalid URL '{0}': {1}")]
InvalidUrl(String, #[source] tonic::transport::Error),
#[error("Invalid URI '{0}': {1}")]
InvalidUri(String, #[source] tonic::transport::Error),
#[error("Failed to connect to '{0}': {1}")]
ConnectionFailed(String, #[source] tonic::transport::Error),
}
/// Errors that can occur during a dynamic call in Online mode.
#[derive(Debug, thiserror::Error)]
pub enum DynamicCallError {
#[error("Reflection resolution failed: '{0}'")]
ReflectionResolve(#[from] ReflectionResolveError),
#[error("Failed to decode file descriptor set: '{0}'")]
DescriptorError(#[from] DescriptorError),
#[error(transparent)]
DynamicCallError(#[from] super::with_file_descriptor::DynamicCallError),
DynamicCallError(#[from] super::online_without_reflection::DynamicCallError),
}
/// Errors that can occur when looking up a descriptor in Online mode.
#[derive(Debug, thiserror::Error)]
pub enum GetDescriptorError {
#[error("Reflection resolution failed: '{0}'")]
@ -50,60 +50,66 @@ pub enum GetDescriptorError {
NotFound(String),
}
impl GrancClient<WithServerReflection<Channel>> {
/// Connects to a gRPC server at the specified address.
impl GrancClient<Online<Channel>> {
/// Connects to a gRPC server and initializes the client in the `Online` state.
///
/// This initializes the client in the **Reflection** state. It establishes a TCP connection
/// but does not yet perform any reflection calls.
/// This is the entry point for interacting with a server. By default, the client assumes
/// the server supports the gRPC Server Reflection Protocol.
///
/// # Arguments
///
/// * `addr` - The URI of the server (e.g., `http://localhost:50051`).
/// * `addr` - The server URI (e.g., `http://localhost:50051`).
///
/// # Returns
///
/// * `Ok(GrancClient<WithServerReflection>)` - The connected client ready to use reflection.
/// * `Err(ClientConnectError)` - If the URL is invalid or the connection cannot be established.
/// * `Ok(GrancClient<Online>)` - A connected client ready to make dynamic requests via reflection.
/// * `Err(ClientConnectError)` - If the URI is invalid or the TCP connection cannot be established.
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))?;
.map_err(|e| ClientConnectError::InvalidUri(addr.to_string(), e))?;
let channel = endpoint
.connect()
.await
.map_err(|e| ClientConnectError::ConnectionFailed(addr.to_string(), e))?;
Ok(Self::from_service(channel))
Ok(GrancClient::from(channel))
}
}
impl<S> GrancClient<WithServerReflection<S>>
impl<S> From<S> for GrancClient<Online<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,
{
/// Creates a new `GrancClient` wrapping an existing Tonic service (e.g., a `Channel` or `InterceptedService`).
///
/// # Arguments
///
/// * `service` - The generic gRPC service implementation to use for transport.
pub fn from_service(service: S) -> Self {
fn from(service: S) -> Self {
let reflection_client = ReflectionClient::new(service.clone());
let grpc_client = GrpcClient::new(service);
Self {
state: WithServerReflection {
state: Online {
reflection_client,
grpc_client,
},
}
}
}
/// Transitions the client to the **File Descriptor** state.
impl<S> GrancClient<Online<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,
{
/// Transitions the client to the **OnlineWithoutReflection** state by loading a local descriptor.
///
/// This method consumes the current client and returns a new client that uses the provided
/// binary `FileDescriptorSet` for all schema lookups, disabling server reflection.
/// This methods consumes the current client and returns a new one that:
/// 1. Retains the existing network connection.
/// 2. Disables Server Reflection lookups.
/// 3. Uses the provided `FileDescriptorSet` for all future schema resolutions.
///
/// This is useful when the server does not support reflection or when you want to enforce
/// a specific schema version.
///
/// # Arguments
///
@ -111,30 +117,35 @@ where
///
/// # Returns
///
/// * `Ok(GrancClient<WithFileDescriptor>)` - The new client state.
/// * `Err(DescriptorError)` - If the provided bytes could not be decoded into a valid `DescriptorPool`.
/// * `Ok(GrancClient<OnlineWithoutReflection>)` - The client in the new state.
/// * `Err(DescriptorError)` - If the provided bytes cannot be decoded into a valid descriptor pool.
pub fn with_file_descriptor(
self,
file_descriptor: Vec<u8>,
) -> Result<GrancClient<WithFileDescriptor<S>>, DescriptorError> {
) -> Result<GrancClient<OnlineWithoutReflection<S>>, DescriptorError> {
let pool = DescriptorPool::decode(file_descriptor.as_slice())?;
Ok(GrancClient::new(self.state.grpc_client, pool))
Ok(GrancClient::new(OnlineWithoutReflection::new(
self.state.grpc_client,
pool,
)))
}
/// Lists all services exposed by the server by querying the reflection endpoint.
/// Lists all services exposed by the server using the Reflection Protocol.
///
/// # Returns
///
/// * `Ok(Vec<String>)` - A list of fully qualified service names (e.g. `helloworld.Greeter`).
/// * `Err(ReflectionResolveError)` - If the reflection call fails or the server doesn't support reflection.
/// * `Ok(Vec<String>)` - A list of fully qualified service names (e.g., `helloworld.Greeter`).
/// * `Err(ReflectionResolveError)` - If the reflection call fails, the stream is closed unexpectedly,
/// or the server returns an error code.
pub async fn list_services(&mut self) -> Result<Vec<String>, ReflectionResolveError> {
self.state.reflection_client.list_services().await
}
/// Resolves and fetches the descriptor for a specific symbol using reflection.
/// Resolves and fetches the descriptor for a specific symbol using Reflection.
///
/// This will recursively fetch the file defining the symbol and all its dependencies
/// from the server.
/// This will query the server for the symbol, fetch the defining file, and recursively fetch
/// all imported dependencies to build a complete `Descriptor`.
///
/// # Arguments
///
@ -143,7 +154,7 @@ where
/// # Returns
///
/// * `Ok(Descriptor)` - The resolved descriptor wrapper.
/// * `Err(GetDescriptorError)` - If the symbol is not found or reflection fails.
/// * `Err(GetDescriptorError)` - If the symbol is not found on the server or the reflection request fails.
pub async fn get_descriptor_by_symbol(
&mut self,
symbol: &str,
@ -163,29 +174,34 @@ where
})?;
let pool = DescriptorPool::from_file_descriptor_set(fd_set)?;
let mut client =
GrancClient::<WithFileDescriptor<S>>::new(self.state.grpc_client.clone(), pool);
let client = GrancClient::new(Offline::new(pool));
client
.get_descriptor_by_symbol(symbol)
.ok_or_else(|| GetDescriptorError::NotFound(symbol.to_string()))
}
/// Executes a dynamic gRPC request using reflection.
///
/// 1. It fetches the schema for the requested `service` via reflection.
/// 2. It builds a temporary `WithFileDescriptor` client using that schema.
/// 3. It delegates the call to that client.
/// Executes a dynamic gRPC request using Server Reflection for schema resolution.
///
/// # Arguments
///
/// * `request` - The [`DynamicRequest`] containing the method to call and the JSON body.
/// * `request` - A [`DynamicRequest`] struct containing:
/// - `service`: The fully qualified name of the service (e.g., `my.package.MyService`).
/// - `method`: The name of the method to call (e.g., `MyMethod`).
/// - `body`: The JSON payload (Object for Unary/ServerStreaming, Array for Client/BiDi Streaming).
/// - `headers`: Optional gRPC metadata/headers.
///
/// # Returns
///
/// * `Ok(DynamicResponse)` - The result of the gRPC call (Unary or Streaming).
/// * `Err(DynamicCallError)` - If schema resolution, validation, or the network call fails.
/// * `Ok(DynamicResponse)` - The result of the call, which can be:
/// - [`DynamicResponse::Unary`]: For Unary and Client Streaming calls (single response).
/// - [`DynamicResponse::Streaming`]: For Server Streaming and Bidirectional calls (stream of responses).
/// * `Err(DynamicCallError)` - If an error occurs during:
/// - Reflection resolution (e.g., Service not found).
/// - Schema parsing.
/// - Request serialization (JSON to Proto).
/// - Network transport.
/// - Response deserialization.
pub async fn dynamic(
&mut self,
request: DynamicRequest,
@ -198,8 +214,10 @@ where
let pool = DescriptorPool::from_file_descriptor_set(fd_set)?;
let mut client =
GrancClient::<WithFileDescriptor<S>>::new(self.state.grpc_client.clone(), pool);
let mut client = GrancClient::new(OnlineWithoutReflection::new(
self.state.grpc_client.clone(),
pool,
));
Ok(client.dynamic(request).await?)
}

View file

@ -0,0 +1,116 @@
//! # Client State: Online Without Reflection
//!
//! This module defines the `GrancClient` behavior when it is connected to a server
//! but uses a local, in-memory `DescriptorPool` (Static schema) to resolve messages.
use super::{DynamicRequest, DynamicResponse, GrancClient, OnlineWithoutReflection};
use crate::{BoxError, client::OfflineReflectionState, grpc::client::GrpcRequestError};
use futures_util::{Stream, StreamExt};
use http_body::Body as HttpBody;
use std::fmt::Debug;
/// Errors that can occur during a dynamic call in OnlineWithoutReflection mode.
#[derive(Debug, thiserror::Error)]
pub enum DynamicCallError {
#[error("Invalid input: '{0}'")]
InvalidInput(String),
#[error("Service '{0}' not found")]
ServiceNotFound(String),
#[error("Method '{0}' not found")]
MethodNotFound(String),
#[error("gRPC client request error: '{0}'")]
GrpcRequestError(#[from] GrpcRequestError),
}
impl<S> GrancClient<OnlineWithoutReflection<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,
{
/// Executes a dynamic gRPC request using the locally loaded `FileDescriptorSet`.
///
/// Unlike the `Online` state, this method does **not** make any calls to the server's reflection endpoint.
/// It relies entirely on the local `DescriptorPool` provided during state transition (see [`super::Online::with_file_descriptor`]).
///
/// # Arguments
///
/// * `request` - A [`DynamicRequest`] struct containing:
/// - `service`: The fully qualified name of the service (e.g., `my.package.MyService`).
/// - `method`: The name of the method to call (e.g., `MyMethod`).
/// - `body`: The JSON payload.
/// - `headers`: Optional gRPC metadata.
///
/// # Returns
///
/// * `Ok(DynamicResponse)` - The result of the call (Unary or Streaming).
/// * `Err(DynamicCallError)` - If validation fails or the network call errors. Specific errors include:
/// - [`DynamicCallError::ServiceNotFound`]: The service is not present in the local descriptor.
/// - [`DynamicCallError::MethodNotFound`]: The method does not exist in the service.
/// - [`DynamicCallError::InvalidInput`]: The JSON body structure is invalid for the streaming mode (e.g. object provided for streaming call).
/// - [`DynamicCallError::GrpcRequestError`]: Transport-level errors (connection failed, timeout, etc).
pub async fn dynamic(
&mut self,
request: DynamicRequest,
) -> Result<DynamicResponse, DynamicCallError> {
let method = self
.state
.descriptor_pool()
.get_service_by_name(&request.service)
.ok_or_else(|| DynamicCallError::ServiceNotFound(request.service.clone()))?
.methods()
.find(|m| m.name() == request.method)
.ok_or_else(|| DynamicCallError::MethodNotFound(request.method.clone()))?;
match (method.is_client_streaming(), method.is_server_streaming()) {
(false, false) => {
let result = self
.state
.grpc_client
.unary(method, request.body, request.headers)
.await?;
Ok(DynamicResponse::Unary(result))
}
(false, true) => match self
.state
.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(DynamicCallError::InvalidInput)?;
let result = self
.state
.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(DynamicCallError::InvalidInput)?;
match self
.state
.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()),
}
}

View file

@ -0,0 +1,63 @@
use prost_reflect::{EnumDescriptor, MessageDescriptor, ServiceDescriptor};
use std::fmt::Debug;
/// A request object encapsulating all necessary information to perform a dynamic gRPC call.
#[derive(Debug, Clone)]
pub struct DynamicRequest {
/// The JSON body of the request.
/// - For Unary/ServerStreaming: An Object `{}`.
/// - For ClientStreaming/Bidirectional: An Array of Objects `[{}]`.
pub body: serde_json::Value,
/// Custom gRPC metadata (headers) to attach to the request.
pub headers: Vec<(String, String)>,
/// The fully qualified name of the service (e.g., `my.package.Service`).
pub service: String,
/// The name of the method to call (e.g., `SayHello`).
pub method: String,
}
/// The result of a dynamic gRPC call.
#[derive(Debug, Clone)]
pub enum DynamicResponse {
/// A single response message (for Unary and Client Streaming calls).
Unary(Result<serde_json::Value, tonic::Status>),
/// A stream of response messages (for Server Streaming and Bidirectional calls).
Streaming(Result<Vec<Result<serde_json::Value, tonic::Status>>, tonic::Status>),
}
/// A generic wrapper for different types of Protobuf descriptors.
///
/// This enum allows the client to return a single type when resolving symbols,
/// regardless of whether the symbol points to a Service, a Message, or an Enum.
#[derive(Debug, Clone)]
pub enum Descriptor {
MessageDescriptor(MessageDescriptor),
ServiceDescriptor(ServiceDescriptor),
EnumDescriptor(EnumDescriptor),
}
impl Descriptor {
/// Returns the inner [`MessageDescriptor`] if this variant is `MessageDescriptor`.
pub fn message_descriptor(&self) -> Option<&MessageDescriptor> {
match self {
Descriptor::MessageDescriptor(d) => Some(d),
_ => None,
}
}
/// Returns the inner [`ServiceDescriptor`] if this variant is `ServiceDescriptor`.
pub fn service_descriptor(&self) -> Option<&ServiceDescriptor> {
match self {
Descriptor::ServiceDescriptor(d) => Some(d),
_ => None,
}
}
/// Returns the inner [`EnumDescriptor`] if this variant is `EnumDescriptor`.
pub fn enum_descriptor(&self) -> Option<&EnumDescriptor> {
match self {
Descriptor::EnumDescriptor(d) => Some(d),
_ => None,
}
}
}

View file

@ -1,178 +0,0 @@
//! # Client State: File Descriptor
//!
//! This module defines the `GrancClient` behavior when it is using a local, in-memory
//! `DescriptorPool` (loaded from a file) to resolve schemas.
//!
//! In this state, the client does **not** use server reflection for schema lookup.
use super::WithFileDescriptor;
use super::{Descriptor, DynamicRequest, DynamicResponse, GrancClient};
use crate::{
BoxError,
grpc::client::{GrpcClient, GrpcRequestError},
};
use futures_util::Stream;
use futures_util::StreamExt;
use http_body::Body as HttpBody;
use prost_reflect::DescriptorPool;
use std::fmt::Debug;
#[derive(Debug, thiserror::Error)]
pub enum DynamicCallError {
#[error("Invalid input: '{0}'")]
InvalidInput(String),
#[error("Service '{0}' not found")]
ServiceNotFound(String),
#[error("Method '{0}' not found")]
MethodNotFound(String),
#[error("gRPC client request error: '{0}'")]
GrpcRequestError(#[from] GrpcRequestError),
}
impl<S> GrancClient<WithFileDescriptor<S>>
where
S: Clone,
{
pub(crate) fn new(grpc_client: GrpcClient<S>, pool: DescriptorPool) -> Self {
Self {
state: WithFileDescriptor { grpc_client, pool },
}
}
}
impl<S> GrancClient<WithFileDescriptor<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,
{
/// Lists all services defined in the loaded `DescriptorPool`.
///
/// Unlike the reflection client, this is a synchronous operation that returns
/// immediately from memory.
///
/// # Returns
///
/// A list of fully qualified service names (e.g. `helloworld.Greeter`).
pub fn list_services(&mut self) -> Vec<String> {
self.state
.pool
.services()
.map(|s| s.full_name().to_string())
.collect()
}
/// Looks up a specific symbol in the loaded `DescriptorPool`.
///
/// # Arguments
///
/// * `symbol` - The fully qualified name of the symbol (Service, Message, or Enum).
///
/// # Returns
///
/// * `Some(Descriptor)` - The resolved descriptor if found.
/// * `None` - If the symbol does not exist in the pool.
pub fn get_descriptor_by_symbol(&mut self, symbol: &str) -> Option<Descriptor> {
let pool = &self.state.pool;
if let Some(descriptor) = pool.get_service_by_name(symbol) {
return Some(Descriptor::ServiceDescriptor(descriptor));
}
if let Some(descriptor) = pool.get_message_by_name(symbol) {
return Some(Descriptor::MessageDescriptor(descriptor));
}
if let Some(descriptor) = pool.get_enum_by_name(symbol) {
return Some(Descriptor::EnumDescriptor(descriptor));
}
None
}
/// Executes a dynamic gRPC request using the loaded `DescriptorPool`.
///
/// It looks up the service and method definitions in the local pool, validates the JSON, and sends the request.
///
/// # Arguments
///
/// * `request` - The [`DynamicRequest`] containing the method to call and the JSON body.
///
/// # Returns
///
/// * `Ok(DynamicResponse)` - The result of the gRPC call.
/// * `Err(DynamicCallError)` - If the service/method is not in the pool, the JSON is invalid, or the call fails.
pub async fn dynamic(
&mut self,
request: DynamicRequest,
) -> Result<DynamicResponse, DynamicCallError> {
let method = self
.state
.pool
.get_service_by_name(&request.service)
.ok_or_else(|| DynamicCallError::ServiceNotFound(request.service))?
.methods()
.find(|m| m.name() == request.method)
.ok_or_else(|| DynamicCallError::MethodNotFound(request.method))?;
match (method.is_client_streaming(), method.is_server_streaming()) {
(false, false) => {
let result = self
.state
.grpc_client
.unary(method, request.body, request.headers)
.await?;
Ok(DynamicResponse::Unary(result))
}
(false, true) => {
match self
.state
.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(DynamicCallError::InvalidInput)?;
let result = self
.state
.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(DynamicCallError::InvalidInput)?;
match self
.state
.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))),
}
}
}
}
}
/// Helper to convert a JSON Array into a Stream of JSON Values.
/// Required for Client and Bidirectional streaming.
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()),
}
}

View file

@ -0,0 +1,49 @@
use echo_service::FILE_DESCRIPTOR_SET;
use granc_core::client::{Descriptor, GrancClient};
#[test]
fn test_offline_list_services() {
let client = GrancClient::offline(FILE_DESCRIPTOR_SET.to_vec())
.expect("Failed to load file descriptor set");
let mut services = client.list_services();
services.sort();
assert_eq!(services.as_slice(), ["echo.EchoService"]);
}
#[test]
fn test_offline_describe_descriptors() {
let client = GrancClient::offline(FILE_DESCRIPTOR_SET.to_vec())
.expect("Failed to load file descriptor set");
// 1. Success: Service
let desc = client
.get_descriptor_by_symbol("echo.EchoService")
.expect("Service not found");
assert!(matches!(
desc,
Descriptor::ServiceDescriptor(s) if s.name() == "EchoService"
));
// 2. Success: Message
let desc = client
.get_descriptor_by_symbol("echo.EchoRequest")
.expect("Message not found");
assert!(matches!(
desc,
Descriptor::MessageDescriptor(m) if m.name() == "EchoRequest"
));
// 3. Error: Symbol Not Found
let desc = client.get_descriptor_by_symbol("echo.Ghost");
assert!(desc.is_none());
}
#[test]
fn test_offline_creation_error() {
let result = GrancClient::offline(vec![0, 1, 2, 3]);
assert!(result.is_err());
}

View file

@ -1,16 +1,14 @@
use echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET};
use echo_service_impl::EchoServiceImpl;
use granc_core::client::{
Descriptor, DynamicRequest, DynamicResponse, GrancClient, WithServerReflection,
with_file_descriptor, with_server_reflection,
};
use granc_core::client::{DynamicRequest, DynamicResponse, GrancClient, Online, online};
use granc_core::reflection::client::ReflectionResolveError;
use tonic::Code;
use tonic::service::Routes;
mod echo_service_impl;
async fn setup_client() -> GrancClient<WithServerReflection<Routes>> {
async fn setup_client() -> GrancClient<Online<Routes>> {
// Enable Reflection
let reflection_service = tonic_reflection::server::Builder::configure()
.register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
.build_v1()
@ -20,15 +18,13 @@ async fn setup_client() -> GrancClient<WithServerReflection<Routes>> {
let service = Routes::new(reflection_service).add_service(echo_service);
GrancClient::from_service(service)
GrancClient::from(service)
}
#[tokio::test]
async fn test_reflection_list_services() {
let mut client = setup_client().await;
let mut services = client.list_services().await.unwrap();
services.sort();
assert_eq!(
@ -38,63 +34,24 @@ async fn test_reflection_list_services() {
}
#[tokio::test]
async fn test_reflection_describe_descriptors() {
async fn test_reflection_unary_success() {
let mut client = setup_client().await;
let desc = client
.get_descriptor_by_symbol("echo.EchoService")
.await
.unwrap();
assert!(matches!(
desc,
Descriptor::ServiceDescriptor(s)
if s.name() == "EchoService"
&& s.methods().any(|m| m.name() == "UnaryEcho")
));
let desc = client
.get_descriptor_by_symbol("echo.EchoRequest")
.await
.unwrap();
assert!(matches!(
desc,
Descriptor::MessageDescriptor(m)
if m.name() == "EchoRequest"
&& m.fields().any(|f| f.name() == "message")
));
}
#[tokio::test]
async fn test_reflection_describe_error() {
let mut client = setup_client().await;
let result = client.get_descriptor_by_symbol("echo.Ghost").await;
assert!(matches!(
result,
Err(with_server_reflection::GetDescriptorError::NotFound(name)) if name == "echo.Ghost"
));
}
#[tokio::test]
async fn test_reflection_dynamic_calls() {
let mut client = setup_client().await;
// Unary Call
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "UnaryEcho".to_string(),
body: serde_json::json!({ "message": "hello" }),
body: serde_json::json!({ "message": "reflection" }),
headers: vec![],
};
let res = client.dynamic(req).await.unwrap();
assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "reflection"));
}
assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "hello"));
#[tokio::test]
async fn test_reflection_server_streaming_success() {
let mut client = setup_client().await;
// Server Streaming
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "ServerStreamingEcho".to_string(),
@ -104,46 +61,38 @@ async fn test_reflection_dynamic_calls() {
let res = client.dynamic(req).await.unwrap();
assert!(matches!(res, DynamicResponse::Streaming(Ok(stream)) if stream.len() == 3));
// Client Streaming
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "ClientStreamingEcho".to_string(),
body: serde_json::json!([
{ "message": "A" },
{ "message": "B" }
]),
headers: vec![],
};
let res = client.dynamic(req).await.unwrap();
assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "AB"));
// Bidirectional Streaming
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "BidirectionalEcho".to_string(),
body: serde_json::json!([
{ "message": "Ping" }
]),
headers: vec![],
};
let res = client.dynamic(req).await.unwrap();
assert!(matches!(res,
DynamicResponse::Streaming(Ok(stream))
if stream.len() == 1
&& stream[0].as_ref().unwrap()["message"] == "echo: Ping"
));
match res {
DynamicResponse::Streaming(Ok(stream)) => {
assert_eq!(stream.len(), 3);
assert_eq!(stream[0].as_ref().unwrap()["message"], "stream - seq 0");
assert_eq!(stream[1].as_ref().unwrap()["message"], "stream - seq 1");
assert_eq!(stream[2].as_ref().unwrap()["message"], "stream - seq 2");
}
_ => panic!("Expected Streaming response"),
}
}
#[tokio::test]
async fn test_reflection_dynamic_error_cases() {
async fn test_reflection_client_streaming_success() {
let mut client = setup_client().await;
// Invalid Service Name
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "ClientStreamingEcho".to_string(),
body: serde_json::json!([{ "message": "A" }, { "message": "B" }]),
headers: vec![],
};
let res = client.dynamic(req).await.unwrap();
assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "AB"));
}
#[tokio::test]
async fn test_reflection_service_not_found() {
let mut client = setup_client().await;
// Requesting a service that doesn't exist on the server.
// This fails during the Reflection Lookup phase.
let req = DynamicRequest {
service: "echo.GhostService".to_string(),
method: "UnaryEcho".to_string(),
@ -155,12 +104,18 @@ async fn test_reflection_dynamic_error_cases() {
assert!(matches!(
result,
Err(with_server_reflection::DynamicCallError::ReflectionResolve(
Err(online::DynamicCallError::ReflectionResolve(
ReflectionResolveError::ServerStreamFailure(status)
)) if status.code() == Code::NotFound
));
}
// Invalid Method Name
#[tokio::test]
async fn test_reflection_method_not_found() {
let mut client = setup_client().await;
// The service exists, so reflection succeeds in fetching the schema.
// However, the schema does not contain "GhostMethod", so it fails locally before call.
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "GhostMethod".to_string(),
@ -172,17 +127,21 @@ async fn test_reflection_dynamic_error_cases() {
assert!(matches!(
result,
Err(with_server_reflection::DynamicCallError::DynamicCallError(
with_file_descriptor::DynamicCallError::MethodNotFound(name)
Err(online::DynamicCallError::DynamicCallError(
granc_core::client::online_without_reflection::DynamicCallError::MethodNotFound(name)
)) if name == "GhostMethod"
));
}
// Invalid JSON Structure (Streaming requires Array, Object provided)
// This triggers `DynamicCallError::InvalidInput` before the request is sent.
#[tokio::test]
async fn test_reflection_invalid_input_structure() {
let mut client = setup_client().await;
// Client streaming requires Array.
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "ClientStreamingEcho".to_string(),
body: serde_json::json!({ "message": "I should be an array" }),
body: serde_json::json!({ "msg": "not array" }),
headers: vec![],
};
@ -190,18 +149,22 @@ async fn test_reflection_dynamic_error_cases() {
assert!(matches!(
result,
Err(with_server_reflection::DynamicCallError::DynamicCallError(
with_file_descriptor::DynamicCallError::InvalidInput(_)
Err(online::DynamicCallError::DynamicCallError(
granc_core::client::online_without_reflection::DynamicCallError::InvalidInput(_)
))
));
}
// Schema Mismatch (Unary)
// Passing a field that doesn't exist. This fails at encoding time inside the Codec.
// Tonic wraps encoding errors as Code::Internal.
#[tokio::test]
async fn test_reflection_schema_mismatch() {
let mut client = setup_client().await;
// Field "wrong_field" does not exist in the protobuf definition.
// Should fail with InvalidArgument during encoding.
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "UnaryEcho".to_string(),
body: serde_json::json!({ "non_existent_field": "oops" }),
body: serde_json::json!({ "wrong_field": "val" }),
headers: vec![],
};

View file

@ -1,16 +1,16 @@
use echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET};
use echo_service_impl::EchoServiceImpl;
use granc_core::client::{
Descriptor, DynamicRequest, DynamicResponse, GrancClient, WithFileDescriptor,
with_file_descriptor,
DynamicRequest, DynamicResponse, GrancClient, OnlineWithoutReflection,
online_without_reflection,
};
use tonic::Code;
mod echo_service_impl;
fn setup_client() -> GrancClient<WithFileDescriptor<EchoServiceServer<EchoServiceImpl>>> {
fn setup_client() -> GrancClient<OnlineWithoutReflection<EchoServiceServer<EchoServiceImpl>>> {
let service = EchoServiceServer::new(EchoServiceImpl);
let client_reflection = GrancClient::from_service(service);
let client_reflection = GrancClient::from(service);
client_reflection
.with_file_descriptor(FILE_DESCRIPTOR_SET.to_vec())
@ -18,49 +18,9 @@ fn setup_client() -> GrancClient<WithFileDescriptor<EchoServiceServer<EchoServic
}
#[tokio::test]
async fn test_list_services() {
async fn test_dynamic_unary_success() {
let mut client = setup_client();
let services = client.list_services();
assert_eq!(services.as_slice(), ["echo.EchoService"]);
}
#[tokio::test]
async fn test_describe_descriptors() {
let mut client = setup_client();
// Describe Service
let desc = client
.get_descriptor_by_symbol("echo.EchoService")
.expect("Service not found");
assert!(matches!(
desc,
Descriptor::ServiceDescriptor(s) if s.name() == "EchoService"
));
// Describe Message
let desc = client
.get_descriptor_by_symbol("echo.EchoRequest")
.expect("Message not found");
assert!(matches!(
desc,
Descriptor::MessageDescriptor(m) if m.name() == "EchoRequest"
));
// Error Case: Returns None
let desc = client.get_descriptor_by_symbol("echo.Ghost");
assert!(desc.is_none());
}
#[tokio::test]
async fn test_dynamic_calls() {
let mut client = setup_client();
// Unary Call
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "UnaryEcho".to_string(),
@ -70,9 +30,16 @@ async fn test_dynamic_calls() {
let res = client.dynamic(req).await.unwrap();
assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "hello"));
assert!(matches!(
res,
DynamicResponse::Unary(Ok(val)) if val["message"] == "hello"
));
}
#[tokio::test]
async fn test_dynamic_server_streaming_success() {
let mut client = setup_client();
// Server Streaming
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "ServerStreamingEcho".to_string(),
@ -82,46 +49,71 @@ async fn test_dynamic_calls() {
let res = client.dynamic(req).await.unwrap();
assert!(matches!(res, DynamicResponse::Streaming(Ok(stream)) if stream.len() == 3));
match res {
DynamicResponse::Streaming(Ok(stream)) => {
assert_eq!(stream.len(), 3);
assert_eq!(stream[0].as_ref().unwrap()["message"], "stream - seq 0");
assert_eq!(stream[1].as_ref().unwrap()["message"], "stream - seq 1");
assert_eq!(stream[2].as_ref().unwrap()["message"], "stream - seq 2");
}
_ => panic!("Expected Streaming response"),
}
}
#[tokio::test]
async fn test_dynamic_client_streaming_success() {
let mut client = setup_client();
// Client Streaming
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "ClientStreamingEcho".to_string(),
// Client streaming requires a JSON Array
body: serde_json::json!([
{ "message": "A" },
{ "message": "B" }
{ "message": "B" },
{ "message": "C" }
]),
headers: vec![],
};
let res = client.dynamic(req).await.unwrap();
assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "AB"));
// Bidirectional Streaming
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "BidirectionalEcho".to_string(),
body: serde_json::json!([
{ "message": "Ping" }
]),
headers: vec![],
};
let res = client.dynamic(req).await.unwrap();
assert!(matches!(res,
DynamicResponse::Streaming(Ok(stream))
if stream.len() == 1
&& stream[0].as_ref().unwrap()["message"] == "echo: Ping"
assert!(matches!(
res,
DynamicResponse::Unary(Ok(val)) if val["message"] == "ABC"
));
}
#[tokio::test]
async fn test_error_cases() {
async fn test_dynamic_bidirectional_streaming_success() {
let mut client = setup_client();
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "BidirectionalEcho".to_string(),
body: serde_json::json!([
{ "message": "Ping" },
{ "message": "Pong" }
]),
headers: vec![],
};
let res = client.dynamic(req).await.unwrap();
match res {
DynamicResponse::Streaming(Ok(stream)) => {
assert_eq!(stream.len(), 2);
assert_eq!(stream[0].as_ref().unwrap()["message"], "echo: Ping");
assert_eq!(stream[1].as_ref().unwrap()["message"], "echo: Pong");
}
_ => panic!("Expected Streaming response"),
}
}
#[tokio::test]
async fn test_error_service_not_found() {
let mut client = setup_client();
// Service Not Found
let req = DynamicRequest {
service: "echo.GhostService".to_string(),
method: "UnaryEcho".to_string(),
@ -133,10 +125,14 @@ async fn test_error_cases() {
assert!(matches!(
result,
Err(with_file_descriptor::DynamicCallError::ServiceNotFound(name)) if name == "echo.GhostService"
Err(online_without_reflection::DynamicCallError::ServiceNotFound(name)) if name == "echo.GhostService"
));
}
#[tokio::test]
async fn test_error_method_not_found() {
let mut client = setup_client();
// Method Not Found
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "GhostMethod".to_string(),
@ -148,10 +144,15 @@ async fn test_error_cases() {
assert!(matches!(
result,
Err(with_file_descriptor::DynamicCallError::MethodNotFound(name)) if name == "GhostMethod"
Err(online_without_reflection::DynamicCallError::MethodNotFound(name)) if name == "GhostMethod"
));
}
// Invalid JSON Structure (Streaming requires Array)
#[tokio::test]
async fn test_error_invalid_input_structure() {
let mut client = setup_client();
// Client streaming requires an Array, passing an Object should fail
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "ClientStreamingEcho".to_string(),
@ -163,11 +164,16 @@ async fn test_error_cases() {
assert!(matches!(
result,
Err(with_file_descriptor::DynamicCallError::InvalidInput(_))
Err(online_without_reflection::DynamicCallError::InvalidInput(_))
));
}
// Schema Mismatch (Unary)
// Field mismatch causes encoding error -> Status::InvalidArgument
#[tokio::test]
async fn test_error_schema_mismatch() {
let mut client = setup_client();
// Passing a field ("unknown_field") that doesn't exist in the EchoRequest proto definition.
// The JsonCodec (in granc-core/src/grpc/codec.rs) maps this to Status::InvalidArgument.
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "UnaryEcho".to_string(),
@ -177,10 +183,12 @@ async fn test_error_cases() {
let result = client.dynamic(req).await;
// This error happens during encoding inside the Tonic stack, so it returns
// a successful Result<DynamicResponse> containing an Err(Status).
assert!(matches!(
result,
Ok(DynamicResponse::Unary(Err(status)))
if status.code() == Code::Internal
&& status.message().contains("JSON structure does not match")
&& status.message().contains("JSON structure does not match Protobuf schema")
));
}

View file

@ -16,6 +16,6 @@ version = "0.6.0"
[dependencies]
clap = { version = "4.5.54", features = ["derive"] }
colored = "3.1.1"
granc_core = "0.5.0"
granc_core = { path = "../granc-core" }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }

View file

@ -1,53 +1,100 @@
//! # CLI
//!
//! 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`);
//! It enforces strict invariants for arguments using subcommands and argument groups.
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use clap::{Args, Parser, Subcommand};
#[derive(Parser)]
#[derive(Parser, Debug)]
#[command(name = "granc", version, about = "Dynamic gRPC CLI")]
pub struct Cli {
/// The server URL to connect to (e.g. http://localhost:50051)
pub url: String,
/// Path to the descriptor set (.bin)
#[arg(long)]
pub file_descriptor_set: Option<PathBuf>,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Perform a gRPC call to a server
/// Perform a gRPC call to a server.
///
/// This command connects to a gRPC server and executes a method using a JSON body.
/// Requires a server URI. Can optionally use a local file descriptor set.
Call {
/// Endpoint (package.Service/Method)
#[arg(value_parser = parse_endpoint)]
endpoint: (String, String),
/// The server URI to connect to (e.g. http://localhost:50051)
#[arg(long, short = 'u')]
uri: String,
/// "JSON body (Object for Unary, Array for Streaming)"
#[arg(long, value_parser = parse_body)]
#[arg(long, short = 'b', value_parser = parse_body)]
body: serde_json::Value,
#[arg(short = 'H', long = "header", value_parser = parse_header)]
headers: Vec<(String, String)>,
/// Optional path to a file descriptor set (.bin) to use instead of reflection
#[arg(long, short = 'f')]
file_descriptor_set: Option<PathBuf>,
},
/// List available services
List,
/// List available services.
///
/// Requires EITHER a server URI (Reflection) OR a file descriptor set (Offline).
List {
#[command(flatten)]
source: SourceSelection,
},
/// Describe a service, message or enum passing the full path
/// Describe a service, message or enum.
///
/// Requires EITHER a server URI (Reflection) OR a file descriptor set (Offline).
Describe {
#[command(flatten)]
source: SourceSelection,
/// Fully qualified name (e.g. my.package.Service)
symbol: String,
},
}
#[derive(Args, Debug)]
#[group(required = true, multiple = false)] // Enforces: Either URI OR FileDescriptorSet, never both.
pub struct SourceSelection {
/// The server URI to use for reflection-based introspection
#[arg(long, short = 'u')]
uri: Option<String>,
/// Path to the descriptor set (.bin) to use for offline introspection
#[arg(long, short = 'f')]
file_descriptor_set: Option<PathBuf>,
}
// The source where to resolve the proto schemas from.
//
// It can either be a URI (If the server supports server streaming)
// or a file (a `.bin` or `.pb` file generated with protoc)
pub enum Source {
Uri(String),
File(PathBuf),
}
impl SourceSelection {
pub fn value(self) -> Source {
if let Some(uri) = self.uri {
Source::Uri(uri)
} else if let Some(path) = self.file_descriptor_set {
Source::File(path)
} else {
// This is unreachable because `clap` verifies the group requirements before we ever get here.
unreachable!(
"Clap ensures exactly one argument (uri or file) is present via #[group(required = true)]"
)
}
}
}
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'",)
@ -69,3 +116,222 @@ fn parse_header(s: &str) -> Result<(String, String), String> {
fn parse_body(value: &str) -> Result<serde_json::Value, String> {
serde_json::from_str(value).map_err(|e| format!("Invalid JSON: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn test_call_command_reflection() {
let args = vec![
"granc",
"call",
"helloworld.Greeter/SayHello",
"--uri",
"http://localhost:50051",
"--body",
r#"{"name": "Ferris"}"#,
];
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
match cli.command {
Commands::Call {
endpoint,
uri,
body,
file_descriptor_set,
..
} => {
assert_eq!(
endpoint,
("helloworld.Greeter".to_string(), "SayHello".to_string())
);
assert_eq!(uri, "http://localhost:50051");
assert_eq!(body, serde_json::json!({"name": "Ferris"}));
assert!(file_descriptor_set.is_none());
}
_ => panic!("Expected Call command"),
}
}
#[test]
fn test_call_command_with_file_descriptor() {
let args = vec![
"granc",
"call",
"helloworld.Greeter/SayHello",
"--uri",
"http://localhost:50051",
"--body",
r#"{"name": "Ferris"}"#,
"--file-descriptor-set",
"./descriptors.bin",
];
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
match cli.command {
Commands::Call {
file_descriptor_set,
..
} => {
assert_eq!(
file_descriptor_set.unwrap().to_str().unwrap(),
"./descriptors.bin"
);
}
_ => panic!("Expected Call command"),
}
}
#[test]
fn test_call_command_short_flags() {
let args = vec![
"granc",
"call",
"svc/mthd",
"-u",
"http://localhost:50051",
"-b",
"{}",
"-f",
"desc.bin",
"-H",
"auth:bearer",
];
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
match cli.command {
Commands::Call {
uri,
file_descriptor_set,
headers,
body,
..
} => {
assert_eq!(uri, "http://localhost:50051");
assert_eq!(file_descriptor_set.unwrap().to_str().unwrap(), "desc.bin");
assert_eq!(body, serde_json::json!({}));
assert_eq!(headers[0], ("auth".to_string(), "bearer".to_string()));
}
_ => panic!("Expected Call command"),
}
}
#[test]
fn test_list_command_reflection() {
let args = vec!["granc", "list", "--uri", "http://localhost:50051"];
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
match cli.command {
Commands::List { source } => {
assert_eq!(source.uri.unwrap(), "http://localhost:50051");
assert!(source.file_descriptor_set.is_none());
}
_ => panic!("Expected List command"),
}
}
#[test]
fn test_list_command_offline() {
let args = vec!["granc", "list", "--file-descriptor-set", "desc.bin"];
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
match cli.command {
Commands::List { source } => {
assert_eq!(
source.file_descriptor_set.unwrap().to_str().unwrap(),
"desc.bin"
);
assert!(source.uri.is_none());
}
_ => panic!("Expected List command"),
}
}
#[test]
fn test_describe_command() {
let args = vec![
"granc",
"describe",
"helloworld.Greeter",
"--uri",
"http://localhost:50051",
];
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
match cli.command {
Commands::Describe { symbol, source } => {
assert_eq!(symbol, "helloworld.Greeter");
assert!(source.uri.is_some());
}
_ => panic!("Expected Describe command"),
}
}
// --- Failure Cases ---
#[test]
fn test_fail_invalid_json_body() {
let args = vec!["granc", "call", "s/m", "-u", "x", "--body", "{invalid_json"];
let err = Cli::try_parse_from(&args).unwrap_err();
// Should verify that the error comes from the body parser
assert!(err.to_string().contains("Invalid JSON"));
}
#[test]
fn test_fail_invalid_endpoint_format() {
let args = vec![
"granc",
"call",
"OnlyServiceNoMethod", // Missing '/'
"-u",
"x",
"-b",
"{}",
];
let err = Cli::try_parse_from(&args).unwrap_err();
assert!(err.to_string().contains("Invalid endpoint format"));
}
#[test]
fn test_fail_list_requires_source() {
let args = vec!["granc", "list"];
let err = Cli::try_parse_from(&args).unwrap_err();
// Clap error for missing required arguments in group
assert!(err.kind() == clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn test_fail_list_mutual_exclusion() {
let args = vec![
"granc",
"list",
"--uri",
"http://host",
"--file-descriptor-set",
"file.bin",
];
let err = Cli::try_parse_from(&args).unwrap_err();
// Clap error for argument conflict
assert!(err.kind() == clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn test_fail_describe_mutual_exclusion() {
let args = vec![
"granc",
"describe",
"Symbol",
"-u",
"http://host",
"-f",
"file.bin",
];
let err = Cli::try_parse_from(&args).unwrap_err();
assert!(err.kind() == clap::error::ErrorKind::ArgumentConflict);
}
}

View file

@ -1,6 +1,6 @@
use colored::*;
use granc_core::{
client::{with_file_descriptor, with_server_reflection},
client::{Descriptor, DynamicResponse, online, online_without_reflection},
prost_reflect::{
self, EnumDescriptor, Kind, MessageDescriptor, MethodDescriptor, ServiceDescriptor,
},
@ -42,16 +42,36 @@ impl From<Status> for FormattedString {
}
}
impl From<DynamicResponse> for FormattedString {
fn from(value: DynamicResponse) -> Self {
match value {
DynamicResponse::Unary(Ok(value)) => FormattedString::from(value),
DynamicResponse::Unary(Err(status)) => FormattedString::from(status),
DynamicResponse::Streaming(Ok(values)) => {
let mut s = String::new();
for elem in values {
match elem {
Ok(val) => s.push_str(&FormattedString::from(val).0),
Err(status) => s.push_str(&FormattedString::from(status).0),
}
}
FormattedString(s)
}
DynamicResponse::Streaming(Err(status)) => FormattedString::from(status),
}
}
}
// Error from Reflection-based calls
impl From<with_server_reflection::DynamicCallError> for FormattedString {
fn from(err: with_server_reflection::DynamicCallError) -> Self {
impl From<online::DynamicCallError> for FormattedString {
fn from(err: online::DynamicCallError) -> Self {
FormattedString(format!("{}\n\n'{}'", "Call Failed:".red().bold(), err))
}
}
// Error from FileDescriptor-based calls
impl From<with_file_descriptor::DynamicCallError> for FormattedString {
fn from(err: with_file_descriptor::DynamicCallError) -> Self {
impl From<online_without_reflection::DynamicCallError> for FormattedString {
fn from(err: online_without_reflection::DynamicCallError) -> Self {
FormattedString(format!("{}\n\n'{}'", "Call Failed:".red().bold(), err))
}
}
@ -82,14 +102,14 @@ impl<T: Display> From<GenericError<T>> for FormattedString {
}
}
impl From<with_server_reflection::ClientConnectError> for FormattedString {
fn from(err: with_server_reflection::ClientConnectError) -> Self {
impl From<online::ClientConnectError> for FormattedString {
fn from(err: online::ClientConnectError) -> Self {
FormattedString(format!("{}\n\n'{}'", "Connection Error:".red().bold(), err))
}
}
impl From<with_server_reflection::GetDescriptorError> for FormattedString {
fn from(err: with_server_reflection::GetDescriptorError) -> Self {
impl From<online::GetDescriptorError> for FormattedString {
fn from(err: online::GetDescriptorError) -> Self {
FormattedString(format!(
"{}\n\n'{}'",
"Symbol Lookup Failed:".red().bold(),
@ -113,6 +133,16 @@ impl From<ServiceList> for FormattedString {
}
}
impl From<Descriptor> for FormattedString {
fn from(value: Descriptor) -> Self {
match value {
Descriptor::MessageDescriptor(d) => FormattedString::from(d),
Descriptor::ServiceDescriptor(d) => FormattedString::from(d),
Descriptor::EnumDescriptor(d) => FormattedString::from(d),
}
}
}
impl From<ServiceDescriptor> for FormattedString {
fn from(service: ServiceDescriptor) -> Self {
let mut out = String::new();

View file

@ -3,149 +3,133 @@
//! 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` (handling state transitions).
//! 4. **Presentation**: Formats and prints the resulting data or errors to standard output/error.
//! 2. **Dispatch**: Routes the command to the appropriate handler based on input arguments
//! (connecting to server vs loading local file).
//! 3. **Execution**: Delegates request processing to `GrancClient`.
//! 4. **Presentation**: Formats and prints data.
mod cli;
mod formatter;
use clap::Parser;
use cli::{Cli, Commands};
use formatter::{FormattedString, GenericError, ServiceList};
use granc_core::client::{
Descriptor, DynamicRequest, DynamicResponse, GrancClient, WithFileDescriptor,
WithServerReflection,
};
use granc_core::tonic::transport::Channel;
use cli::{Cli, Commands, Source};
use formatter::{FormattedString, GenericError};
use granc_core::client::{Descriptor, DynamicRequest, DynamicResponse, GrancClient};
use std::process;
#[tokio::main]
async fn main() {
let args = Cli::parse();
let client = unwrap_or_exit(GrancClient::connect(&args.url).await);
match args.command {
Commands::Call {
endpoint,
uri,
body,
headers,
file_descriptor_set,
} => {
let response = call(endpoint, uri, body, headers, file_descriptor_set).await;
println!("{}", FormattedString::from(response))
}
if let Some(path) = args.file_descriptor_set {
let bytes = unwrap_or_exit(std::fs::read(&path));
let client = unwrap_or_exit(client.with_file_descriptor(bytes));
handle_file_descriptor_mode(client, args.command).await;
} else {
handle_reflection_mode(client, args.command).await;
Commands::List { source } => {
let services = list(source.value()).await;
println!(
"{}",
FormattedString::from(formatter::ServiceList(services))
)
}
Commands::Describe { symbol, source } => {
let descriptor = describe(symbol, source.value()).await;
println!("{}", FormattedString::from(descriptor))
}
}
}
async fn handle_reflection_mode(
mut client: GrancClient<WithServerReflection<Channel>>,
command: Commands,
) {
match command {
Commands::Call {
endpoint,
body,
headers,
} => {
async fn call(
endpoint: (String, String),
uri: String,
body: serde_json::Value,
headers: Vec<(String, String)>,
file_descriptor_set: Option<std::path::PathBuf>,
) -> DynamicResponse {
let (service, method) = endpoint;
let request = DynamicRequest {
body,
headers,
service,
method,
body,
headers,
};
let response = unwrap_or_exit(client.dynamic(request).await);
print_response(response);
let mut client = GrancClient::connect(&uri).await.unwrap_or_exit();
if let Some(path) = file_descriptor_set {
let bytes = std::fs::read(path).unwrap_or_exit();
let mut client = client.with_file_descriptor(bytes).unwrap_or_exit();
client.dynamic(request).await.unwrap_or_exit()
} else {
client.dynamic(request).await.unwrap_or_exit()
}
Commands::List => {
let services = unwrap_or_exit(
}
async fn list(source: Source) -> Vec<String> {
match source {
Source::Uri(uri) => {
let mut client = GrancClient::connect(&uri).await.unwrap_or_exit();
client
.list_services()
.await
.map_err(|err| GenericError("Failed to list services:", err)),
);
println!("{}", FormattedString::from(ServiceList(services)));
.map_err(|e| GenericError("Failed to list services:", e))
.unwrap_or_exit()
}
Commands::Describe { symbol } => {
let descriptor = unwrap_or_exit(client.get_descriptor_by_symbol(&symbol).await);
print_descriptor(descriptor);
Source::File(path) => {
let fd_bytes = std::fs::read(path).unwrap_or_exit();
let client = GrancClient::offline(fd_bytes).unwrap_or_exit();
client.list_services()
}
}
}
// --- Handler for File Descriptor Mode ---
async fn handle_file_descriptor_mode(
mut client: GrancClient<WithFileDescriptor<Channel>>,
command: Commands,
) {
match command {
Commands::Call {
endpoint,
body,
headers,
} => {
let (service, method) = endpoint;
let request = DynamicRequest {
body,
headers,
service,
method,
};
let response = unwrap_or_exit(client.dynamic(request).await);
print_response(response);
}
Commands::List => {
let services = client.list_services();
println!("{}", FormattedString::from(ServiceList(services)));
}
Commands::Describe { symbol } => {
let descriptor = unwrap_or_exit(
async fn describe(symbol: String, source: Source) -> Descriptor {
match source {
Source::Uri(uri) => {
let mut client = GrancClient::connect(&uri).await.unwrap_or_exit();
client
.get_descriptor_by_symbol(&symbol)
.ok_or(GenericError("Symbol not found", symbol)),
);
print_descriptor(descriptor);
.await
.unwrap_or_exit()
}
Source::File(path) => {
let fd_bytes = std::fs::read(path).unwrap_or_exit();
let client = GrancClient::offline(fd_bytes).unwrap_or_exit();
client
.get_descriptor_by_symbol(&symbol)
.ok_or(GenericError("Symbol not found", symbol))
.unwrap_or_exit()
}
}
}
/// Helper function to return the Ok value or print the error and exit.
fn unwrap_or_exit<T, E>(result: Result<T, E>) -> T
// Utility trait to standardize the way we handle errors in the program
trait UnwrapOrExit<T, E> {
fn unwrap_or_exit(self) -> T;
}
impl<T, E> UnwrapOrExit<T, E> for Result<T, E>
where
E: Into<FormattedString>,
{
match result {
fn unwrap_or_exit(self) -> T {
match self {
Ok(v) => v,
Err(e) => {
eprintln!("{}", Into::<FormattedString>::into(e));
process::exit(1);
}
}
}
fn print_descriptor(descriptor: Descriptor) {
match descriptor {
Descriptor::MessageDescriptor(d) => println!("{}", FormattedString::from(d)),
Descriptor::ServiceDescriptor(d) => println!("{}", FormattedString::from(d)),
Descriptor::EnumDescriptor(d) => println!("{}", FormattedString::from(d)),
}
}
fn print_response(response: DynamicResponse) {
match response {
DynamicResponse::Unary(Ok(value)) => println!("{}", FormattedString::from(value)),
DynamicResponse::Unary(Err(status)) => println!("{}", FormattedString::from(status)),
DynamicResponse::Streaming(Ok(values)) => {
for elem in values {
match elem {
Ok(val) => println!("{}", FormattedString::from(val)),
Err(status) => println!("{}", FormattedString::from(status)),
}
}
}
DynamicResponse::Streaming(Err(status)) => {
println!("{}", FormattedString::from(status))
}
}
}