feat: implement list and describe commands (#26)

This pull request introduces major improvements to the `granc` gRPC CLI, focusing on enhanced introspection and discovery features, a more user-friendly command-line interface, and improved error and output formatting. The changes include new commands for listing and describing services, methods, and messages, a restructured CLI argument parser, and a new formatter for colored, readable output. Additionally, the core client is extended to support these new features, and error handling is refactored for clarity.

**New CLI features and UX improvements:**

* Added new `list` and `describe` commands to the CLI, allowing users to discover available services and inspect service/message definitions directly from the server using reflection. The CLI argument structure is now subcommand-based for better usability. [[1]](diffhunk://#diff-dfa67e7f5e147119fe8d665da6b31b3605f5e196734ec7407aab2bcc9e2f656cL8-R84) [[2]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5L71-R139)
* Updated the README with documentation for the new commands and improved usage instructions. [[1]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R21) [[2]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5L71-R139)

**Core client and reflection enhancements:**

* Implemented new methods in the core client for listing services and fetching symbol descriptors via reflection, including robust error types for each operation. [[1]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adL25-R27) [[2]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adL38-R78) [[3]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adR149-R211) [[4]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adL125-R229) [[5]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adL154-R252) [[6]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adL164-R262) [[7]](diffhunk://#diff-13deee04dd97de938cc46f0ef4faca083f3b471800e94cf45937122b83f01d57R19) [[8]](diffhunk://#diff-13deee04dd97de938cc46f0ef4faca083f3b471800e94cf45937122b83f01d57R124-R161)

**Output formatting and error handling:**

* Added a new `formatter` module for producing colored, human-friendly output for all major CLI operations, including pretty-printing of service lists, descriptors, and errors.
* Improved error handling throughout the client and CLI, with more specific error types and user-facing messages. [[1]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adL38-R78) [[2]](diffhunk://#diff-0de6f761cf394791a15b0707e1a41f54559b5626f7aedb06ef339bc1a7ca6287R1-R248)

**Dependency and project structure updates:**

* Updated dependencies and added the `colored` crate for output styling.
This commit is contained in:
Víctor Martínez 2026-01-24 19:39:59 +01:00 committed by GitHub
parent 191120c1d4
commit 26e46a4003
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 819 additions and 194 deletions

36
Cargo.lock generated
View file

@ -203,6 +203,15 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "colored"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "echo-service"
version = "0.0.0"
@ -327,10 +336,11 @@ dependencies = [
[[package]]
name = "granc"
version = "0.3.1"
version = "0.4.0"
dependencies = [
"clap",
"granc_core 0.2.4",
"colored",
"granc_core",
"serde_json",
"tokio",
"tonic",
@ -345,27 +355,7 @@ dependencies = [
[[package]]
name = "granc_core"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b70761ae61e50a3da701f80dec0a5768e43420895922e233894865ce3969cea"
dependencies = [
"futures-util",
"http",
"http-body",
"prost",
"prost-reflect",
"prost-types",
"serde_json",
"thiserror",
"tokio",
"tokio-stream",
"tonic",
"tonic-prost",
]
[[package]]
name = "granc_core"
version = "0.3.1"
version = "0.3.2"
dependencies = [
"echo-service",
"futures-util",

View file

@ -20,25 +20,14 @@ It is heavily inspired by tools like `grpcurl` but built to leverage the safety
* **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.
* **Introspection Tools**: Commands to list services and describe services/messages.
* **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 .
cargo install --locked granc
```
## 🛠️ Prerequisites
@ -68,69 +57,79 @@ protoc \
**Syntax:**
```bash
granc [OPTIONS] <URL> <ENDPOINT>
granc <URL> <COMMAND> [ARGS]
```
### Arguments
### Global 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** |
| `<URL>` | Server address (e.g., `http://[::1]:50051`). Must be the first argument. | **Yes** |
### Options
### Commands
| 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 |
#### 1. `call` (Make Requests)
### 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.
Performs a gRPC call using a JSON body.
```bash
# Using Reflection (no descriptor file needed)
granc \
--body '{"name": "Ferris"}' \
http://localhost:50051 \
helloworld.Greeter/SayHello
granc http://localhost:50051 call <ENDPOINT> --body <JSON> [OPTIONS]
```
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.
| 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 |
| `--file-descriptor-set` | Path to the binary FileDescriptorSet (`.bin`) if not using reflection. | No |
### JSON Body Format
##### JSON Body Format
* **Unary / Server Streaming**: Provide a single JSON object `{ ... }`.
* **Client / Bidirectional Streaming**: Provide a JSON array of objects `[ { ... }, { ... } ]`.
### Examples
##### Automatic Server Reflection
**1. Unary Call (using local descriptor)**
If you omit the `--file-descriptor-set` flag, Granc will automatically attempt to connect to the server's reflection service to download the necessary schemas.
```bash
granc \
--proto-set ./descriptor.bin \
--body '{"name": "Ferris"}' \
http://localhost:50051 \
helloworld.Greeter/SayHello
granc http://localhost:50051 call --body '{"name": "Ferris"}' helloworld.Greeter/SayHello
```
**2. Bidirectional Streaming (Chat)**
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.
#### 2. `list` (Service Discovery) (Server reflection required)
Lists all services exposed by the server.
```bash
granc \
--body '[{"text": "Hello"}, {"text": "How are you?"}]' \
-H "authorization: Bearer token123" \
http://localhost:50051 \
chat.ChatService/StreamMessages
granc http://localhost:50051 list
```
#### 3. `describe` (Introspection) (Server reflection required)
Inspects services, messages or enums and prints their Protobuf definition.
**Describe Service:**
Describe in detail all methods of a service.
```bash
granc http://localhost:50051 describe my.package.Greeter
```
**Describe Message:**
Shows the fields of a specific message type.
```bash
granc http://localhost:50051 describe my.package.HelloRequest
```
## 🔮 Roadmap
* **Interactive Mode**: A REPL for streaming requests interactively.
* **Pretty Printing**: Enhanced colored output for JSON responses.
* **Pretty Printing JSON**: Enhanced colored output for JSON responses.
* **TLS Support**: Configurable root certificates and client identity.
## 🧩 Using as a Library

View file

@ -8,27 +8,17 @@
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:
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.
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.
### 1. Making a Dynamic Call
### Example: Making a Dynamic Call
The `dynamic` method handles the full request lifecycle:
1. Resolves the schema (either from a local file or via Server Reflection).
2. Determines the method type (Unary, Server Streaming, etc.).
3. Executes the request using JSON.
```rust
use granc_core::client::{GrancClient, DynamicRequest, DynamicResponse};
@ -72,6 +62,25 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
```
### 2. Schema Introspection
`GrancClient` exposes several methods to inspect the server's available services and types using reflection.
```rust
// List all services exposed by the server
let services = client.list_services().await?;
println!("Available Services: {:?}", services);
// Get the descriptor for a specific type
let descriptor = client.get_descriptor_by_symbol("helloworld.Greeter").await?;
match descriptor {
Descriptor::MessageDescriptor(descriptor)) => println!("{}", descriptor.name())
Descriptor::ServiceDescriptor(descriptor)) => println!("{}", descriptor.name())
Descriptor::EnumDescriptor(descriptor)) => println!("{}", descriptor.name())
}
```
## 🛠️ 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.
@ -110,13 +119,19 @@ The magic behind the dynamic serialization. This implementation of `tonic::codec
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 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.
```rust
use granc_core::reflection::client::ReflectionClient;
let mut reflection = ReflectionClient::new(channel);
// List services
let services = reflection.list_services().await?;
// Fetch full schema for a symbol
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.

View file

@ -1,20 +1,37 @@
//! # 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.
//! This module implements the high-level logic for executing dynamic gRPC requests
//! and offers support for reflection operations if the server supports it.
//!
//! ## Responsibilities
//! The [`GrancClient`] is the primary entry point for consumers of this library.
//! It abstracts away the complexity of connection management, schema resolution (reflection vs. file descriptors),
//! and generic gRPC transport.
//!
//! 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.
//! ## Example Usage
//!
//! ```rust,no_run
//! use granc_core::client::{GrancClient, DynamicRequest};
//! use serde_json::json;
//!
//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
//! // 1. Connect to the server
//! let mut client = GrancClient::connect("http://localhost:50051").await?;
//!
//! // 2. Prepare the request (using server reflection)
//! let request = DynamicRequest {
//! service: "helloworld.Greeter".to_string(),
//! method: "SayHello".to_string(),
//! body: json!({ "name": "Ferris" }),
//! headers: vec![],
//! file_descriptor_set: None,
//! };
//!
//! // 3. Execute the call
//! let response = client.dynamic(request).await?;
//! println!("Response: {:?}", response);
//! # Ok(())
//! # }
//! ```
use crate::{
BoxError,
grpc::client::{GrpcClient, GrpcRequestError},
@ -22,9 +39,14 @@ use crate::{
};
use futures_util::Stream;
use http_body::Body as HttpBody;
use prost_reflect::{DescriptorError, DescriptorPool};
use prost_reflect::{
DescriptorError, DescriptorPool, EnumDescriptor, MessageDescriptor, ServiceDescriptor,
};
use tokio_stream::StreamExt;
use tonic::transport::{Channel, Endpoint};
use tonic::{
Code,
transport::{Channel, Endpoint},
};
#[derive(Debug, thiserror::Error)]
pub enum ClientConnectError {
@ -35,13 +57,26 @@ pub enum ClientConnectError {
}
#[derive(Debug, thiserror::Error)]
pub enum DynamicRequestError {
pub enum ListServicesError {
#[error("Reflection resolution failed: '{0}'")]
ReflectionResolve(#[from] ReflectionResolveError),
}
#[derive(Debug, thiserror::Error)]
pub enum GetDescriptorError {
#[error("Reflection resolution failed: '{0}'")]
ReflectionResolve(#[from] ReflectionResolveError),
#[error("Failed to decode file descriptor set: '{0}'")]
DescriptorError(#[from] DescriptorError),
#[error("Descriptor at path '{0}' not found")]
NotFound(String),
}
#[derive(Debug, thiserror::Error)]
pub enum DynamicCallError {
#[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),
@ -58,25 +93,81 @@ pub enum DynamicRequestError {
GrpcRequestError(#[from] GrpcRequestError),
}
/// A request object encapsulating all necessary information to perform a dynamic gRPC call.
pub struct DynamicRequest {
/// Optional binary `FileDescriptorSet` (e.g. generated by `protoc`).
/// If `None`, the client will attempt to use Server Reflection.
pub file_descriptor_set: Option<Vec<u8>>,
/// 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.
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 file descriptor of either a message, service or enum
#[derive(Debug, Clone)]
pub enum Descriptor {
MessageDescriptor(MessageDescriptor),
ServiceDescriptor(ServiceDescriptor),
EnumDescriptor(EnumDescriptor),
}
impl Descriptor {
pub fn message_descriptor(&self) -> Option<&MessageDescriptor> {
match self {
Descriptor::MessageDescriptor(message_descriptor) => Some(message_descriptor),
_ => None,
}
}
pub fn service_descriptor(&self) -> Option<&ServiceDescriptor> {
match self {
Descriptor::ServiceDescriptor(service_descriptor) => Some(service_descriptor),
_ => None,
}
}
pub fn enum_descriptor(&self) -> Option<&EnumDescriptor> {
match self {
Descriptor::EnumDescriptor(enum_descriptor) => Some(enum_descriptor),
_ => None,
}
}
}
/// The main client for interacting with gRPC servers dynamically.
///
/// It combines a [`ReflectionClient`] for schema discovery and a [`GrpcClient`] for
/// generic transport.
pub struct GrancClient<S = Channel> {
reflection_client: ReflectionClient<S>,
grpc_client: GrpcClient<S>,
}
impl GrancClient<Channel> {
/// Connects to a gRPC server at the specified address.
///
/// # Arguments
///
/// * `addr` - The URI of the server (e.g., `http://localhost:50051`).
///
/// # Errors
///
/// Returns a [`ClientConnectError`] if the URL is invalid or the 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))?;
@ -96,6 +187,7 @@ where
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`).
pub fn new(service: S) -> Self {
let reflection_client = ReflectionClient::new(service.clone());
let grpc_client = GrpcClient::new(service);
@ -106,10 +198,91 @@ where
}
}
/// Fetches the list of all available services exposed by the server.
///
/// This method relies on the server supporting the gRPC Reflection Protocol (`grpc.reflection.v1`).
///
/// # Returns
///
/// A list of fully qualified service names (e.g., `["grpc.reflection.v1.ServerReflection", "my.app.Greeter"]`).
pub async fn list_services(&mut self) -> Result<Vec<String>, ListServicesError> {
self.reflection_client
.list_services()
.await
.map_err(Into::into)
}
/// Resolves and fetches the [`Descriptor`] for a specific symbol using reflection.
///
/// # Arguments
///
/// * `symbol` - The fully qualified name of the type (e.g., `my.package.Service`).
///
/// # Errors
///
/// Returns an error if the descriptor cannot be found via reflection or if the resolved descriptor set is invalid.
pub async fn get_descriptor_by_symbol(
&mut self,
symbol: &str,
) -> Result<Descriptor, GetDescriptorError> {
let fd_set = self
.reflection_client
.file_descriptor_set_by_symbol(symbol)
.await
.map_err(|err| match err {
ReflectionResolveError::ServerStreamFailure(status)
if status.code() == Code::NotFound =>
{
GetDescriptorError::NotFound(symbol.to_string())
}
err => GetDescriptorError::ReflectionResolve(err),
})?;
let pool = DescriptorPool::from_file_descriptor_set(fd_set)?;
if let Some(descriptor) = pool.get_service_by_name(symbol) {
return Ok(Descriptor::ServiceDescriptor(descriptor));
}
if let Some(descriptor) = pool.get_message_by_name(symbol) {
return Ok(Descriptor::MessageDescriptor(descriptor));
}
if let Some(descriptor) = pool.get_enum_by_name(symbol) {
return Ok(Descriptor::EnumDescriptor(descriptor));
}
Err(GetDescriptorError::NotFound(symbol.to_string()))
}
/// Executes a dynamic gRPC request.
///
/// This is the core method of the client. It bridges the user's intent (JSON data)
/// to the network (gRPC/Protobuf) by resolving schemas and dispatching the call.
///
/// # The Process
///
/// 1. **Schema Resolution**: It builds a [`DescriptorPool`] either by decoding the provided
/// `file_descriptor_set` (if present in `request`) or by querying the server's reflection
/// endpoint for the requested `service` symbol.
/// 2. **Method Lookup**: It searches the pool for the specified `service` and `method`.
/// 3. **Dispatch**: Based on whether the method is Client Streaming, Server Streaming, etc.,
/// it invokes the appropriate low-level transport method on [`GrpcClient`].
/// 4. **Transcoding**: The internal codec handles the conversion between `serde_json::Value`
/// and Protobuf bytes on the fly.
///
/// # Errors
///
/// Returns [`DynamicCallError`] if:
/// * The file descriptor set can't be decoded.
/// * A file descriptor set can't be resolved via reflection (In case a file descriptor set it not passed).
/// * The service or method does not exist in the file descriptor set.
/// * The input JSON is not valid for the type of call (e.g Using a single JSON for a client stream request).
/// * The gRPC request fails.
pub async fn dynamic(
&mut self,
request: DynamicRequest,
) -> Result<DynamicResponse, DynamicRequestError> {
) -> Result<DynamicResponse, DynamicCallError> {
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
@ -122,14 +295,12 @@ where
}
};
let service = pool
let method = pool
.get_service_by_name(&request.service)
.ok_or_else(|| DynamicRequestError::ServiceNotFound(request.service))?;
let method = service
.ok_or_else(|| DynamicCallError::ServiceNotFound(request.service))?
.methods()
.find(|m| m.name() == request.method)
.ok_or_else(|| DynamicRequestError::MethodNotFound(request.method))?;
.ok_or_else(|| DynamicCallError::MethodNotFound(request.method))?;
match (method.is_client_streaming(), method.is_server_streaming()) {
(false, false) => {
@ -151,8 +322,8 @@ where
}
}
(true, false) => {
let input_stream = json_array_to_stream(request.body)
.map_err(DynamicRequestError::InvalidInput)?;
let input_stream =
json_array_to_stream(request.body).map_err(DynamicCallError::InvalidInput)?;
let result = self
.grpc_client
.client_streaming(method, input_stream, request.headers)
@ -161,8 +332,8 @@ where
}
(true, true) => {
let input_stream = json_array_to_stream(request.body)
.map_err(DynamicRequestError::InvalidInput)?;
let input_stream =
json_array_to_stream(request.body).map_err(DynamicCallError::InvalidInput)?;
match self
.grpc_client
.bidirectional_streaming(method, input_stream, request.headers)
@ -176,6 +347,8 @@ where
}
}
/// 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> {

View file

@ -1,21 +1,28 @@
//! # Reflection Client
//!
//! A client implementation for `grpc.reflection.v1`.
//! This module provides a client implementation for the gRPC Server Reflection Protocol (`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.
//! The [`ReflectionClient`] allows `granc` to inspect the schema of a running gRPC server at runtime.
//! It is capable of:
//!
//! 1. **Listing Services**: Querying the server for all exposed service names.
//! 2. **Symbol Resolution**: Fetching the `FileDescriptorProto` for a specific symbol (Service or Message).
//! 3. **Dependency Management**: Automatically identifying missing imports in a file descriptor and recursively
//! fetching them from the server to build a complete, self-contained `FileDescriptorSet`.
//!
//! This client is designed to be resilient and handles the recursive graph traversal required to reconstruct
//! the full proto set from individual file descriptors.
//!
//! ## References
//!
//! * [gRPC Server Reflection Protocol](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md)//!
//! * [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 futures_util::stream::once;
use http_body::Body as HttpBody;
use prost::Message;
use prost_types::{FileDescriptorProto, FileDescriptorSet};
@ -25,6 +32,7 @@ use tokio_stream::wrappers::ReceiverStream;
use tonic::transport::Channel;
use tonic::{Streaming, client::GrpcService};
/// Errors that can occur during reflection resolution.
#[derive(Debug, thiserror::Error)]
pub enum ReflectionResolveError {
#[error(
@ -56,7 +64,7 @@ pub enum ReflectionResolveError {
// So we won't enforce it from the user.
const EMPTY_HOST: &str = "";
/// A generic client for the gRPC Server Reflection Protocol.
/// A client for interacting with the gRPC Server Reflection Service.
pub struct ReflectionClient<T = Channel> {
client: ServerReflectionClient<T>,
}
@ -68,25 +76,34 @@ where
S::ResponseBody: HttpBody<Data = tonic::codegen::Bytes> + Send + 'static,
<S::ResponseBody as HttpBody>::Error: Into<BoxError> + Send,
{
/// Creates a new `ReflectionClient` using the provided gRPC service (e.g., a `Channel`).
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`).
/// Fetches the complete `FileDescriptorSet` containing the definition for the given symbol.
///
/// **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.
/// This method performs a recursive lookup:
/// 1. It asks the server for the file defining `service_name`.
/// 2. It parses the response and identifies any imported files (dependencies).
/// 3. It recursively requests those dependencies if they haven't been fetched yet.
/// 4. It aggregates all fetched files into a single `FileDescriptorSet`.
///
/// This ensures that the returned set is self-contained and can be used to build a
/// `prost_reflect::DescriptorPool`.
///
/// # Arguments
///
/// * `symbol` - The fully qualified symbol name to resolve (e.g., `my.package.MyService`, `my.package.Message`).
///
/// # Returns
///
/// * `Ok(fd_set)` - Successful reflection requests execution.
/// * `Err(ReflectionResolveError)` - Failed to request file descriptors to the reflection service.
/// * `Ok(FileDescriptorSet)` - A set containing the file defining the symbol and all its transitive dependencies.
/// * `Err(ReflectionResolveError)` - If the symbol is not found, the server doesn't support reflection, or a protocol error occurs.
pub async fn file_descriptor_set_by_symbol(
&mut self,
service_name: &str,
symbol: &str,
) -> Result<FileDescriptorSet, ReflectionResolveError> {
// Initialize Stream
let (tx, rx) = mpsc::channel(100);
@ -101,9 +118,7 @@ where
// Send Initial Request
let req = ServerReflectionRequest {
host: EMPTY_HOST.to_string(),
message_request: Some(MessageRequest::FileContainingSymbol(
service_name.to_string(),
)),
message_request: Some(MessageRequest::FileContainingSymbol(symbol.to_string())),
};
tx.send(req)
@ -120,6 +135,51 @@ where
Ok(fd_set)
}
/// Lists all services exposed by the server.
///
/// Sends a `ListServices` request to the reflection endpoint.
///
/// # Returns
///
/// * `Ok(Vec<String>)` - A string list where each string is a fully qualified service name (e.g., `grpc.reflection.v1.ServerReflection`, `helloworld.Greeter`).
/// * `Err(ReflectionResolveError)` - If the server doesn't support reflection or a protocol error occurs.
pub async fn list_services(&mut self) -> Result<Vec<String>, ReflectionResolveError> {
let req = ServerReflectionRequest {
host: EMPTY_HOST.to_string(),
message_request: Some(MessageRequest::ListServices(String::new())),
};
let mut response_stream = self
.client
.server_reflection_info(once(async { req }))
.await
.map_err(ReflectionResolveError::ServerStreamInitFailed)?
.into_inner();
let response = response_stream
.message()
.await
.map_err(ReflectionResolveError::ServerStreamFailure)?
.ok_or(ReflectionResolveError::StreamClosed)?;
match response.message_response {
Some(MessageResponse::ListServicesResponse(resp)) => {
let services = resp.service.into_iter().map(|s| s.name).collect();
Ok(services)
}
Some(MessageResponse::ErrorResponse(e)) => Err(ReflectionResolveError::ServerError {
code: e.error_code,
message: e.error_message,
}),
Some(other) => Err(ReflectionResolveError::UnexpectedResponseType(format!(
"{other:?}",
))),
None => Err(ReflectionResolveError::UnexpectedResponseType(
"Empty Message".into(),
)),
}
}
}
async fn collect_descriptors(

View file

@ -2,9 +2,18 @@ use echo_service::EchoServiceServer;
use echo_service::FILE_DESCRIPTOR_SET;
use echo_service_impl::EchoServiceImpl;
use granc_core::client::{DynamicRequest, DynamicResponse, GrancClient};
use tonic_reflection::server::v1::ServerReflectionServer;
mod echo_service_impl;
fn reflection_service()
-> ServerReflectionServer<impl tonic_reflection::server::v1::ServerReflection> {
tonic_reflection::server::Builder::configure()
.register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
.build_v1()
.expect("Failed to setup Reflection Service")
}
#[tokio::test]
async fn test_unary() {
let payload = serde_json::json!({ "message": "hello" });
@ -126,3 +135,79 @@ async fn test_bidirectional_streaming() {
_ => panic!("Received unary response for bidirectional streaming request"),
};
}
#[tokio::test]
async fn test_list_services_success() {
let mut client = GrancClient::new(reflection_service());
let services = client
.list_services()
.await
.expect("Failed to list services");
// We expect "echo.EchoService" because we registered it.
// The list usually also includes the reflection service itself ("grpc.reflection.v1.ServerReflection").
assert!(
services.contains(&"echo.EchoService".to_string()),
"Services list did not contain 'echo.EchoService'. Found: {:?}",
services
);
}
#[tokio::test]
async fn test_get_service_descriptor_success() {
let mut client = GrancClient::new(reflection_service());
let descriptor = client
.get_descriptor_by_symbol("echo.EchoService")
.await
.expect("Failed to get service descriptor");
let descriptor = descriptor.service_descriptor().unwrap();
assert_eq!(descriptor.name(), "EchoService");
assert_eq!(descriptor.full_name(), "echo.EchoService");
// Verify methods are present
let method_names: Vec<String> = descriptor.methods().map(|m| m.name().to_string()).collect();
assert!(method_names.contains(&"UnaryEcho".to_string()));
assert!(method_names.contains(&"ServerStreamingEcho".to_string()));
assert!(method_names.contains(&"ClientStreamingEcho".to_string()));
assert!(method_names.contains(&"BidirectionalEcho".to_string()));
}
#[tokio::test]
async fn test_get_message_descriptor_success() {
let mut client = GrancClient::new(reflection_service());
let desc = client
.get_descriptor_by_symbol("echo.EchoRequest")
.await
.expect("Failed to get message descriptor");
let desc = desc.message_descriptor().unwrap();
assert_eq!(desc.name(), "EchoRequest");
assert_eq!(desc.full_name(), "echo.EchoRequest");
// Verify fields
let fields: Vec<String> = desc.fields().map(|f| f.name().to_string()).collect();
assert!(fields.contains(&"message".to_string()));
}
#[tokio::test]
async fn test_get_descriptor_not_found() {
let mut client = GrancClient::new(reflection_service());
// "echo.GhostService" does not exist in the registered descriptors.
// The reflection client should fail to find the symbol, resulting in a ResolutionError.
let err = client
.get_descriptor_by_symbol("echo.GhostService")
.await
.unwrap_err();
assert!(matches!(
err,
granc_core::client::GetDescriptorError::NotFound(_)
));
}

View file

@ -15,7 +15,8 @@ version = "0.4.0"
[dependencies]
clap = { version = "4.5.54", features = ["derive"] }
granc_core = "0.3.2"
colored = "3.1.1"
granc_core = { path = "../granc-core" }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
tonic = { workspace = true }

View file

@ -5,25 +5,52 @@
//! It is responsible for parsing user input and performing validation (e.g., ensuring headers are `key:value`);
use std::path::PathBuf;
use clap::Parser;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "granc", version, about = "Dynamic gRPC CLI")]
pub struct Cli {
#[arg(long, help = "Path to the descriptor set (.bin)")]
pub file_descriptor_set: Option<PathBuf>,
#[arg(long, help = "JSON body (Object for Unary, Array for Streaming)", value_parser = parse_body)]
pub body: serde_json::Value,
#[arg(short = 'H', long = "header", value_parser = parse_header)]
pub headers: Vec<(String, String)>,
#[arg(help = "Server URL (http://host:port)")]
/// The server URL to connect to (e.g. http://localhost:50051)
pub url: String,
#[arg(help = "Endpoint (package.Service/Method)", value_parser = parse_endpoint)]
pub endpoint: (String, String),
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
/// Perform a gRPC call to a server
///
/// This command connects to a gRPC server and executes a method using a JSON body.
///
/// ## Examples:
///
/// ```bash
/// granc call http://localhost:50051 my.pkg.Service/Method --body '{"key": "value"}'
/// ```
Call {
/// Endpoint (package.Service/Method)
#[arg(value_parser = parse_endpoint)]
endpoint: (String, String),
/// "JSON body (Object for Unary, Array for Streaming)"
#[arg(long, value_parser = parse_body)]
body: serde_json::Value,
#[arg(short = 'H', long = "header", value_parser = parse_header)]
headers: Vec<(String, String)>,
/// Path to the descriptor set (.bin)
#[arg(long)]
file_descriptor_set: Option<PathBuf>,
},
/// List available services
List,
/// Describe a service, message or enum passing the full path
Describe {
/// Fully qualified name (e.g. my.package.Service)
symbol: String,
},
}
fn parse_endpoint(value: &str) -> Result<(String, String), String> {

223
granc/src/formatter.rs Normal file
View file

@ -0,0 +1,223 @@
use colored::*;
use granc_core::{
client::{ClientConnectError, DynamicCallError, GetDescriptorError, ListServicesError},
prost_reflect::{EnumDescriptor, Kind, MessageDescriptor, MethodDescriptor, ServiceDescriptor},
};
use tonic::Status;
/// A wrapper struct for a formatted, colored string.
///
/// Implements `Display` so it can be printed directly.
pub struct FormattedString(pub String);
pub struct ServiceList(pub Vec<String>);
impl std::fmt::Display for FormattedString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f)?;
writeln!(f, "{}", self.0)?;
Ok(())
}
}
impl From<serde_json::Value> for FormattedString {
fn from(value: serde_json::Value) -> Self {
FormattedString(serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()))
}
}
impl From<Status> for FormattedString {
fn from(status: Status) -> Self {
FormattedString(format!(
"{} code={:?} message={:?}",
"gRPC Failed:".red().bold(),
status.code(),
status.message()
))
}
}
impl From<std::io::Error> for FormattedString {
fn from(err: std::io::Error) -> Self {
FormattedString(format!(
"{}\n\n'{}'",
"Failed to read file:".red().bold(),
err
))
}
}
impl From<ClientConnectError> for FormattedString {
fn from(err: ClientConnectError) -> Self {
FormattedString(format!("{}\n\n'{}'", "Connection Error:".red().bold(), err))
}
}
impl From<ListServicesError> for FormattedString {
fn from(err: ListServicesError) -> Self {
FormattedString(format!(
"{}\n\n'{}'",
"Failed to list services:".red().bold(),
err
))
}
}
impl From<GetDescriptorError> for FormattedString {
fn from(err: GetDescriptorError) -> Self {
FormattedString(format!(
"{}\n\n'{}'",
"Symbol Lookup Failed:".red().bold(),
err
))
}
}
impl From<DynamicCallError> for FormattedString {
fn from(err: DynamicCallError) -> Self {
FormattedString(format!("{}\n\n'{}'", "Call Failed:".red().bold(), err))
}
}
impl From<ServiceList> for FormattedString {
fn from(ServiceList(services): ServiceList) -> Self {
if services.is_empty() {
return FormattedString("No services found.".yellow().to_string());
}
let mut out = String::new();
out.push_str("Available Services:\n");
for svc in services {
out.push_str(&format!(" - {}\n", svc.green()));
}
FormattedString(out.trim_end().to_string())
}
}
impl From<ServiceDescriptor> for FormattedString {
fn from(service: ServiceDescriptor) -> Self {
let mut out = String::new();
out.push_str(&format!(
"{} {} {{\n",
"service".cyan(),
service.name().green()
));
for method in service.methods() {
out.push_str(" ");
// Reuse the From<MethodDescriptor> implementation
let method_fmt = FormattedString::from(method);
out.push_str(&method_fmt.0);
out.push_str("\n\n");
}
out.push('}');
FormattedString(out)
}
}
impl From<MethodDescriptor> for FormattedString {
fn from(method: MethodDescriptor) -> Self {
let input_stream = if method.is_client_streaming() {
format!("{} ", "stream".cyan())
} else {
"".to_string()
};
let output_stream = if method.is_server_streaming() {
format!("{} ", "stream".cyan())
} else {
"".to_string()
};
FormattedString(format!(
"{} {}({}{}) {} ({}{});",
"rpc".cyan(),
method.name().green(),
input_stream,
method.input().full_name().yellow(),
"returns".cyan(),
output_stream,
method.output().full_name().yellow()
))
}
}
impl From<MessageDescriptor> for FormattedString {
fn from(message: MessageDescriptor) -> Self {
let mut out = String::new();
out.push_str(&format!(
"{} {} {{\n",
"message".cyan(),
message.name().green()
));
for field in message.fields() {
let label = if field.is_list() {
format!("{} ", "repeated".cyan())
} else {
"".to_string()
};
let type_name = match field.kind() {
Kind::Double => "double".yellow(),
Kind::Float => "float".yellow(),
Kind::Int32 => "int32".yellow(),
Kind::Int64 => "int64".yellow(),
Kind::Uint32 => "uint32".yellow(),
Kind::Uint64 => "uint64".yellow(),
Kind::Sint32 => "sint32".yellow(),
Kind::Sint64 => "sint64".yellow(),
Kind::Fixed32 => "fixed32".yellow(),
Kind::Fixed64 => "fixed64".yellow(),
Kind::Sfixed32 => "sfixed32".yellow(),
Kind::Sfixed64 => "sfixed64".yellow(),
Kind::Bool => "bool".yellow(),
Kind::String => "string".yellow(),
Kind::Bytes => "bytes".yellow(),
Kind::Message(m) => m.full_name().yellow(),
Kind::Enum(e) => e.full_name().yellow(),
};
if field.is_map() {
out.push_str(&format!(
" // map entry: {} {} = {};\n",
type_name,
field.name(),
field.number()
));
} else {
out.push_str(&format!(
" {}{}{} {} = {};\n",
label,
type_name,
" ".normal(), // Reset color
field.name(),
field.number()
));
}
}
out.push('}');
FormattedString(out)
}
}
impl From<EnumDescriptor> for FormattedString {
fn from(enum_desc: EnumDescriptor) -> Self {
let mut out = String::new();
out.push_str(&format!(
"{} {} {{\n",
"enum".cyan(),
enum_desc.name().green()
));
for val in enum_desc.values() {
out.push_str(&format!(
" {} = {};\n",
val.name(),
val.number().to_string().purple()
));
}
out.push('}');
FormattedString(out)
}
}

View file

@ -5,77 +5,129 @@
//! 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.
//! 4. **Presentation**: Formats and prints the resulting data or errors to standard output/error.
mod cli;
mod formatter;
use clap::Parser;
use cli::Cli;
use granc_core::client::{DynamicRequest, DynamicResponse, GrancClient};
use cli::{Cli, Commands};
use formatter::FormattedString;
use granc_core::client::{Descriptor, DynamicRequest, DynamicResponse, GrancClient};
use std::process;
use crate::formatter::ServiceList;
#[tokio::main]
async fn main() {
let args = Cli::parse();
// The URL is now a global argument, available for all commands
let url = args.url;
let file_descriptor_set = match args.file_descriptor_set.map(std::fs::read).transpose() {
Ok(fd) => fd,
Err(err) => {
eprintln!("Error reading file descriptor set: '{err}'");
process::exit(1);
match args.command {
Commands::Call {
endpoint,
body,
headers,
file_descriptor_set,
} => {
let (service, method) = endpoint;
run_call(url, service, method, body, headers, file_descriptor_set).await;
}
};
Commands::List => list_services(&url).await,
Commands::Describe { symbol } => describe_type(&url, &symbol).await,
}
}
let (service, method) = args.endpoint;
let request = DynamicRequest {
file_descriptor_set,
body: args.body,
headers: args.headers,
service,
method,
};
let mut client = match GrancClient::connect(&args.url).await {
async fn connect_or_exit(url: &str) -> GrancClient {
match GrancClient::connect(url).await {
Ok(client) => client,
Err(err) => {
eprintln!("Error: {err}");
process::exit(1);
}
};
match client.dynamic(request).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}");
eprintln!("{}", FormattedString::from(err));
process::exit(1);
}
}
}
fn print_json(val: &serde_json::Value) {
println!(
"{}",
serde_json::to_string_pretty(val).unwrap_or_else(|_| val.to_string())
);
async fn list_services(url: &str) {
let mut client = connect_or_exit(url).await;
match client.list_services().await {
Ok(services) => {
println!("{}", FormattedString::from(ServiceList(services)));
}
Err(e) => {
eprintln!("{}", FormattedString::from(e));
process::exit(1);
}
}
}
fn print_status(status: &tonic::Status) {
eprintln!(
"gRPC Failed: code={:?} message={:?}",
status.code(),
status.message()
);
async fn describe_type(url: &str, symbol: &str) {
let mut client = connect_or_exit(url).await;
match client.get_descriptor_by_symbol(symbol).await {
Ok(Descriptor::MessageDescriptor(descriptor)) => {
println!("{}", FormattedString::from(descriptor))
}
Ok(Descriptor::ServiceDescriptor(descriptor)) => {
println!("{}", FormattedString::from(descriptor))
}
Ok(Descriptor::EnumDescriptor(descriptor)) => {
println!("{}", FormattedString::from(descriptor))
}
Err(e) => {
eprintln!("{}", FormattedString::from(e));
process::exit(1);
}
}
}
async fn run_call(
url: String,
service: String,
method: String,
body: serde_json::Value,
headers: Vec<(String, String)>,
file_descriptor_set: Option<std::path::PathBuf>,
) {
let file_descriptor_set = match file_descriptor_set.map(std::fs::read).transpose() {
Ok(fd) => fd,
Err(err) => {
eprintln!("{}", FormattedString::from(err));
process::exit(1);
}
};
let request = DynamicRequest {
file_descriptor_set,
body,
headers,
service,
method,
};
let mut client = connect_or_exit(&url).await;
match client.dynamic(request).await {
Ok(DynamicResponse::Unary(Ok(value))) => println!("{}", FormattedString::from(value)),
Ok(DynamicResponse::Unary(Err(status))) => println!("{}", FormattedString::from(status)),
Ok(DynamicResponse::Streaming(Ok(values))) => print_stream(&values),
Ok(DynamicResponse::Streaming(Err(status))) => {
println!("{}", FormattedString::from(status))
}
Err(err) => {
eprintln!("{}", FormattedString::from(err));
process::exit(1);
}
}
}
fn print_stream(stream: &[Result<serde_json::Value, tonic::Status>]) {
for elem in stream {
match elem {
Ok(val) => print_json(val),
Err(status) => print_status(status),
Ok(val) => println!("{}", FormattedString::from(val.clone())),
Err(status) => println!("{}", FormattedString::from(status.clone())),
}
}
}