mirror of
https://codeberg.org/JasterV/granc.git
synced 2026-04-26 18:40:05 +00:00
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:
parent
191120c1d4
commit
26e46a4003
10 changed files with 819 additions and 194 deletions
36
Cargo.lock
generated
36
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
93
README.md
93
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(_)
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
223
granc/src/formatter.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_or_exit(url: &str) -> GrancClient {
|
||||
match GrancClient::connect(url).await {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
eprintln!("{}", FormattedString::from(err));
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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!("Error reading file descriptor set: '{err}'");
|
||||
eprintln!("{}", FormattedString::from(err));
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let (service, method) = args.endpoint;
|
||||
|
||||
let request = DynamicRequest {
|
||||
file_descriptor_set,
|
||||
body: args.body,
|
||||
headers: args.headers,
|
||||
body,
|
||||
headers,
|
||||
service,
|
||||
method,
|
||||
};
|
||||
|
||||
let mut client = match GrancClient::connect(&args.url).await {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
eprintln!("Error: {err}");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
let mut client = connect_or_exit(&url).await;
|
||||
|
||||
match client.dynamic(request).await {
|
||||
Ok(DynamicResponse::Unary(Ok(value))) => print_json(&value),
|
||||
Ok(DynamicResponse::Unary(Err(status))) => print_status(&status),
|
||||
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))) => print_status(&status),
|
||||
Ok(DynamicResponse::Streaming(Err(status))) => {
|
||||
println!("{}", FormattedString::from(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())
|
||||
);
|
||||
}
|
||||
|
||||
fn print_status(status: &tonic::Status) {
|
||||
eprintln!(
|
||||
"gRPC Failed: code={:?} message={:?}",
|
||||
status.code(),
|
||||
status.message()
|
||||
);
|
||||
}
|
||||
|
||||
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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue