From 9990e94c8c040b03f5a2b69b440dbdec1074da81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= <49537445+JasterV@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:09:41 +0100 Subject: [PATCH] [fix] A URL should not be required for list and describe commands (#35) solves #34 --- Cargo.lock | 22 +- README.md | 92 ++++-- granc-core/README.md | 118 +++---- granc-core/src/client.rs | 147 ++++----- granc-core/src/client/offline.rs | 73 +++++ .../{with_server_reflection.rs => online.rs} | 132 ++++---- .../src/client/online_without_reflection.rs | 116 +++++++ granc-core/src/client/types.rs | 63 ++++ granc-core/src/client/with_file_descriptor.rs | 178 ----------- granc-core/tests/granc_client_offline_test.rs | 49 +++ ...on_test.rs => granc_client_online_test.rs} | 167 ++++------ ..._client_online_without_reflection_test.rs} | 162 +++++----- granc/Cargo.toml | 2 +- granc/src/cli.rs | 302 ++++++++++++++++-- granc/src/formatter.rs | 48 ++- granc/src/main.rs | 212 ++++++------ 16 files changed, 1121 insertions(+), 762 deletions(-) create mode 100644 granc-core/src/client/offline.rs rename granc-core/src/client/{with_server_reflection.rs => online.rs} (50%) create mode 100644 granc-core/src/client/online_without_reflection.rs create mode 100644 granc-core/src/client/types.rs delete mode 100644 granc-core/src/client/with_file_descriptor.rs create mode 100644 granc-core/tests/granc_client_offline_test.rs rename granc-core/tests/{granc_client_reflection_test.rs => granc_client_online_test.rs} (52%) rename granc-core/tests/{granc_client_file_descriptor_test.rs => granc_client_online_without_reflection_test.rs} (53%) diff --git a/Cargo.lock b/Cargo.lock index de3b75c..b0a552b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -340,7 +340,7 @@ version = "0.6.0" dependencies = [ "clap", "colored", - "granc_core 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "granc_core", "serde_json", "tokio", ] @@ -364,26 +364,6 @@ dependencies = [ "tonic-reflection", ] -[[package]] -name = "granc_core" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4375e91b7a00f76c4248eeaa67ce5fe0f62087c6c3139f2b6588c06982fce1cd" -dependencies = [ - "futures-util", - "http", - "http-body", - "prost", - "prost-reflect", - "prost-types", - "serde_json", - "thiserror", - "tokio", - "tokio-stream", - "tonic", - "tonic-reflection", -] - [[package]] name = "h2" version = "0.4.13" diff --git a/README.md b/README.md index 2170b7e..b95491c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ It allows you to make gRPC calls to any server using simple JSON payloads, witho It is heavily inspired by tools like `grpcurl` but built to leverage the safety and performance of the Rust ecosystem (Tonic + Prost). - ## 🚀 Features * **Dynamic Encoding/Decoding**: Transcodes JSON to Protobuf (and vice versa) on the fly using `prost-reflect`. @@ -30,6 +29,7 @@ It is heavily inspired by tools like `grpcurl` but built to leverage the safety ```bash cargo install --locked granc + ``` ## 🛠️ Prerequisites @@ -50,6 +50,7 @@ protoc \ --descriptor_set_out=descriptor.bin \ --proto_path=. \ my_service.proto + ``` > **Note**: The `--include_imports` flag is crucial. It ensures that types defined in imported files (like `google/protobuf/timestamp.proto`) are available for reflection. @@ -59,16 +60,10 @@ protoc \ **Syntax:** ```bash -granc [OPTIONS] [ARGS] +granc [ARGS] + ``` -### Global Arguments - -| Argument | Description | Required | -| --- | --- | --- | -| `` | Server address (e.g., `http://[::1]:50051`). Must be the first argument. | **Yes** | -| `--file-descriptor-set` | Path to the binary FileDescriptorSet (`.bin`). If omitted, Granc attempts to use Server Reflection. | No | - ### Commands #### 1. `call` (Make Requests) @@ -76,59 +71,97 @@ granc [OPTIONS] [ARGS] Performs a gRPC call using a JSON body. ```bash -granc http://localhost:50051 [OPTIONS] call --body [ARGS] +granc call --uri --body [OPTIONS] + ``` -| Argument/Flag | Description | Required | -| --- | --- | --- | -| `` | Fully qualified method name (e.g., `my.package.Service/Method`). | **Yes** | -| `--body` | The request body in JSON format. Object `{}` for unary, Array `[]` for streaming. | **Yes** | -| `--header`, `-H` | Custom header `key:value`. Can be used multiple times. | No | +| Argument/Flag | Short | Description | Required | +| --- | --- | --- | --- | +| `` | | Fully qualified method name (e.g., `my.package.Service/Method`). | **Yes** | +| `--uri` | `-u` | Server address (e.g., `http://[::1]:50051`). | **Yes** | +| `--body` | `-b` | The request body in JSON format. Object `{}` for unary, Array `[]` for streaming. | **Yes** | +| `--header` | `-H` | Custom header `key:value`. Can be used multiple times. | No | +| `--file-descriptor-set` | `-f` | Path to a local `.bin` descriptor file to use instead of reflection. | No | **Example using Server Reflection:** ```bash -granc http://localhost:50051 call helloworld.Greeter/SayHello --body '{"name": "Ferris"}' +granc call helloworld.Greeter/SayHello --uri http://localhost:50051 --body '{"name": "Ferris"}' + ``` ```json { "message": "Hello Ferris" } + ``` **Example using a Local Descriptor File:** ```bash -granc http://localhost:50051 --file-descriptor-set ./descriptors.bin call helloworld.Greeter/SayHello --body '{"name": "Ferris"}' +granc call helloworld.Greeter/SayHello \ + --uri http://localhost:50051 \ + --file-descriptor-set ./descriptors.bin \ + --body '{"name": "Ferris"}' + ``` #### 2. `list` (Service Discovery) -Lists all services exposed by the server (via reflection) or contained in the provided descriptor file. +Lists all services exposed by the server (via reflection) or contained in the provided descriptor file. You must provide **either** a URI or a file descriptor set. ```bash -granc http://localhost:50051 list +granc list [OPTIONS] + +``` + +| Flag | Short | Description | +| --- | --- | --- | +| `--uri` | `-u` | Use Server Reflection to list available services. | +| `--file-descriptor-set` | `-f` | Use a local file to list contained services (offline). | + +**Listing services via Reflection:** + +```bash +granc list --uri http://localhost:50051 + ``` ``` Available Services: - grpc.reflection.v1.ServerReflection - helloworld.Greeter + ``` -**Listing services from a file:** +**Listing services from a file (Offline):** ```bash -granc http://localhost:50051 --file-descriptor-set ./descriptors.bin list +granc list --file-descriptor-set ./descriptors.bin + ``` #### 3. `describe` (Introspection) -Inspects a specific symbol (Service, Message, or Enum) and prints its Protobuf definition in a colored, human-readable format. +Inspects a specific symbol (Service, Message, or Enum) and prints its Protobuf definition in a colored, human-readable format. You must provide **either** a URI or a file descriptor set. ```bash -granc http://localhost:50051 describe helloworld.Greeter +granc describe [OPTIONS] + +``` + +| Argument/Flag | Short | Description | +| --- | --- | --- | +| `` | | Fully qualified name of the Service, Message, or Enum. | +| `--uri` | `-u` | Use Server Reflection to resolve the symbol. | +| `--file-descriptor-set` | `-f` | Use a local file to resolve the symbol (offline). | + +**Describing a Service via Reflection:** + +```bash +granc describe helloworld.Greeter --uri http://localhost:50051 + ``` ```proto @@ -136,26 +169,30 @@ service Greeter { rpc SayHello(helloworld.HelloRequest) returns (helloworld.HelloReply); rpc StreamHello(stream helloworld.HelloRequest) returns (stream helloworld.HelloReply); } + ``` **Describing a Message using a Local File:** ```bash -granc http://localhost:50051 --file-descriptor-set ./descriptors.bin describe helloworld.HelloRequest +granc describe helloworld.HelloRequest --file-descriptor-set ./descriptors.bin + ``` -```proto +```proto message HelloRequest { string name = 1; int32 age = 2; repeated string tags = 3; } + ``` **Describing an Enum:** ```bash -granc http://localhost:50051 describe my.package.Status +granc describe my.package.Status --uri http://localhost:50051 + ``` ```proto @@ -164,6 +201,7 @@ enum Status { ACTIVE = 1; INACTIVE = 2; } + ``` ## 🔮 Roadmap @@ -178,7 +216,7 @@ The core logic of Granc is decoupled into a separate library crate, **`granc-cor If you want to build your own tools using the dynamic gRPC engine (e.g., for custom integration testing, proxies, or automation tools), you can depend on `granc-core` directly. -* **Documentation & Usage**: See the **[`granc-core` README](https://www.google.com/search?q=./granc-core/README.md)** for examples on how to use the `GrancClient` programmatically. +* **Documentation & Usage**: See the **[`granc-core` README](./granc-core/README.md)** for examples on how to use the `GrancClient` programmatically. * **Crate**: [`granc-core`](https://crates.io/crates/granc_core) ## ⚠️ Common Errors diff --git a/granc-core/README.md b/granc-core/README.md index 98cf98f..b0bc703 100644 --- a/granc-core/README.md +++ b/granc-core/README.md @@ -10,13 +10,15 @@ Instead of strictly typed Rust structs, this library bridges standard `serde_jso ## 🚀 High-Level Usage -The primary entry point is the [`GrancClient`]. It acts as an orchestrator that connects to a gRPC server and provides methods for both executing requests and inspecting the server's schema. +The primary entry point is the [`GrancClient`]. It uses a **Typestate Pattern** to ensure safety and correctness regarding how the Protobuf schema is resolved. There are three distinct states: -To ensure safety and correctness, `GrancClient` uses a **Typestate Pattern**. It starts in a state that relies on Server Reflection, but can transition to a state that uses a local `FileDescriptorSet`. +1. **[`Online`]**: Connected to a server, uses Server Reflection (Async introspection). +2. **[`OnlineWithoutReflection`]**: Connected to a server, uses a local `FileDescriptorSet` (Sync introspection). +3. **[`Offline`]**: Disconnected, uses a local `FileDescriptorSet` (Sync introspection). -### 1. Using Server Reflection (Default) +### 1. Online (Server Reflection) -By default, when you connect, the client is ready to use the server's reflection service to resolve methods and types dynamically. +This is the default state when you connect. The client queries the server's reflection endpoint to dynamically discover services and message formats. ```rust use granc_core::client::{GrancClient, DynamicRequest, DynamicResponse}; @@ -24,91 +26,81 @@ use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box> { - // Connect (starts in Reflection mode) + // 1. Connect (Starts in 'Online' state) let mut client = GrancClient::connect("http://localhost:50051").await?; + // 2. Introspection (Async via Reflection) + let services = client.list_services().await?; + println!("Server services: {:?}", services); + + // 3. Dynamic Call let request = DynamicRequest { service: "helloworld.Greeter".to_string(), method: "SayHello".to_string(), - body: json!({ "name": "World" }), + body: json!({ "name": "Ferris" }), headers: vec![], }; - // Execute (Schema is fetched automatically via reflection) + // Schema is fetched automatically from the server let response = client.dynamic(request).await?; - - match response { - DynamicResponse::Unary(Ok(value)) => println!("Response: {}", value), - DynamicResponse::Unary(Err(status)) => eprintln!("gRPC Error: {:?}", status), - _ => {} - } + println!("{:?}", response); Ok(()) } ``` -### 2. Using a Local Descriptor File +### 2. OnlineWithoutReflection (Local Schema) -If you have a `.bin` file generated by `protoc`, you can load it into the client. This transforms the client's state, disabling reflection and forcing it to look up schemas in the provided file. +Use this state if you are connecting to a server that does not support reflection, or if you want to enforce a specific schema version from a local file. ```rust use granc_core::client::GrancClient; #[tokio::main] async fn main() -> Result<(), Box> { - // Connect (starts in Reflection mode) let client = GrancClient::connect("http://localhost:50051").await?; - - // Read the descriptor file let descriptor_bytes = std::fs::read("descriptor.bin")?; - // Transition to File Descriptor mode + // Transition state: Online -> OnlineWithoutReflection let mut client = client.with_file_descriptor(descriptor_bytes)?; - // Now use this client for requests. It will NOT query the server for schema. - let services = client.list_services(); - println!("Services in file: {:?}", services); + // Introspection is now SYNCHRONOUS (in-memory) + let services = client.list_services(); + println!("Local services: {:?}", services); + + // Dynamic calls use the local schema to encode/decode + // client.dynamic(req).await?; Ok(()) } ``` -### 3. Schema Introspection +### 3. Offline (Introspection Only) -Both client states expose methods to inspect the available schema, but their APIs differ slightly because reflection requires network calls (async) while file lookups are in-memory (sync). - -#### Using Server Reflection (Async) +This state is useful for building tools that need to inspect `.bin` descriptor files without establishing a network connection. ```rust -// List available services (requires network call) -let services = client.list_services().await?; +use granc_core::client::GrancClient; -// Get a specific descriptor (requires network call) -// Returns Result -let descriptor = client.get_descriptor_by_symbol("helloworld.Greeter").await?; +fn main() -> Result<(), Box> { + let descriptor_bytes = std::fs::read("descriptor.bin")?; -match descriptor { - Descriptor::ServiceDescriptor(svc) => println!("Service: {}", svc.name()), - Descriptor::MessageDescriptor(msg) => println!("Message: {}", msg.name()), - Descriptor::EnumDescriptor(enm) => println!("Enum: {}", enm.name()), + // Create directly in 'Offline' state + let client = GrancClient::offline(descriptor_bytes)?; + + // Sync introspection methods + let services = client.list_services(); + + if let Some(descriptor) = client.get_descriptor_by_symbol("helloworld.Greeter") { + println!("Found service: {:?}", descriptor); + } + + // Note: client.dynamic() is NOT available in this state. + Ok(()) } -``` -#### Using Local File (Sync) - -```rust -// List available services (immediate, can't fail) -let services = client_fd.list_services(); - -// Get a specific descriptor (immediate) -// Returns Option -if let Some(descriptor) = client_fd.get_descriptor_by_symbol("helloworld.Greeter") { - println!("Found symbol: {:?}", descriptor); -} else { - println!("Symbol not found in file"); -} ``` ## 🛠️ Internal Components @@ -117,39 +109,15 @@ We expose the internal building blocks of `granc` for developers who need more g ### 1. `GrpcClient` (Generic Transport) -Standard `tonic` clients are strongly typed (e.g., `client.say_hello(HelloRequest)`). -`GrpcClient` is a generic wrapper around `tonic::client::Grpc` that works strictly with `serde_json::Value` and `prost_reflect::MethodDescriptor`. - -It handles the raw HTTP/2 path construction and metadata mapping, providing specific methods for all four gRPC access patterns: - -* `unary` -* `server_streaming` -* `client_streaming` -* `bidirectional_streaming` - -```rust -use granc_core::grpc::client::GrpcClient; -// You need a method_descriptor from prost_reflect::DescriptorPool -// let method_descriptor = ...; - -let mut grpc = GrpcClient::new(channel); -let result = grpc.unary(method_descriptor, json_value, headers).await?; - -``` +Standard `tonic` clients are strongly typed. `GrpcClient` is a generic wrapper around `tonic::client::Grpc` that works strictly with `serde_json::Value` and `prost_reflect::MethodDescriptor`. It handles the raw HTTP/2 path construction and metadata mapping. ### 2. `JsonCodec` The magic behind the dynamic serialization. This implementation of `tonic::codec::Codec` validates and transcodes JSON to Protobuf bytes (and vice versa) on the fly. -* **Encoder**: Validates `serde_json::Value` against the input `MessageDescriptor` and serializes it. -* **Decoder**: Deserializes bytes into a `DynamicMessage` and converts it back to `serde_json::Value`. - ### 3. `ReflectionClient` -A client for `grpc.reflection.v1`. It enables runtime schema discovery. - -The `ReflectionClient` is smart enough to handle dependencies. When you ask for a symbol (e.g., `my.package.Service`), -it recursively fetches the file defining that symbol and **all** its transitive imports, building a complete `prost_types::FileDescriptorSet` ready for use. It also supports listing available services. +A robust client for `grpc.reflection.v1`. It automatically handles transitive dependency resolution, recursively fetching all imported files to build a complete, self-contained `FileDescriptorSet`. ## ⚖️ License diff --git a/granc-core/src/client.rs b/granc-core/src/client.rs index cf0a0d7..ce16deb 100644 --- a/granc-core/src/client.rs +++ b/granc-core/src/client.rs @@ -3,14 +3,15 @@ //! This module implements the high-level logic for executing dynamic gRPC requests. //! //! The [`GrancClient`] uses a **Typestate Pattern** to ensure safety and correctness regarding -//! how the Protobuf schema is resolved. It has two possible states: +//! how the Protobuf schema is resolved. It has three possible states: //! -//! 1. **[`WithServerReflection`]**: The default state. The client is connected -//! to a server and uses the gRPC Server Reflection Protocol (`grpc.reflection.v1`) to discover -//! services and fetch schemas on the fly. -//! 2. **[`WithFileDescriptor`]**: The client has been provided with a specific -//! binary `FileDescriptorSet` (e.g., loaded from a `.bin` file). In this state, reflection is -//! disabled, and all lookups are performed against the provided file. +//! 1. **[`Online`]**: The default state when connecting. The client uses the gRPC +//! Server Reflection Protocol (`grpc.reflection.v1`) to discover services. +//! 2. **[`OnlineWithoutReflection`]**: The client is connected to a server but uses a local +//! binary `FileDescriptorSet` for schema lookups. +//! 3. **[`Offline`]**: The client is **not connected** to any server. It holds a +//! local `FileDescriptorSet` and can only be used for introspection (Listing services, describing symbols), +//! but cannot perform gRPC calls. //! //! ## Example: State Transition //! @@ -18,109 +19,89 @@ //! use granc_core::client::GrancClient; //! //! # async fn run() -> Result<(), Box> { -//! // Connect (starts in Reflection state) -//! let mut client_reflection = GrancClient::connect("http://localhost:50051").await?; +//! // 1. Online State (Reflection) +//! let mut client = GrancClient::connect("http://localhost:50051").await?; //! -//! // The API here is async -//! let services = client_reflection.list_services().await?; -//! -//! // 2Transition to File Descriptor state +//! // 2. Transition to OnlineWithoutReflection (Connected + Local Schema) //! let bytes = std::fs::read("descriptor.bin")?; -//! let mut client_fd = client_reflection.with_file_descriptor(bytes)?; +//! let mut client_static = client.with_file_descriptor(bytes)?; //! -//! // Now operations use the local file and are sync -//! let services = client_fd.list_services(); +//! // 3. Offline State (Disconnected + Local Schema) +//! let bytes = std::fs::read("descriptor.bin")?; +//! let mut client_offline = GrancClient::offline(bytes)?; //! # Ok(()) //! # } //! ``` -pub mod with_file_descriptor; -pub mod with_server_reflection; +pub mod offline; +pub mod online; +pub mod online_without_reflection; +mod types; + +pub use types::*; use crate::{grpc::client::GrpcClient, reflection::client::ReflectionClient}; -use prost_reflect::{DescriptorPool, EnumDescriptor, MessageDescriptor, ServiceDescriptor}; +use prost_reflect::DescriptorPool; use std::fmt::Debug; use tonic::transport::Channel; /// The main client for interacting with gRPC servers dynamically. /// -/// The generic parameter `T` represents the current state of the client, determining -/// its capabilities and how it resolves Protobuf schemas. +/// The generic parameter `T` represents the current state of the client. #[derive(Clone, Debug)] pub struct GrancClient { state: T, } -/// The state for a client that uses a local `DescriptorPool` for schema resolution. -#[derive(Debug, Clone)] -pub struct WithFileDescriptor { - grpc_client: GrpcClient, - pool: DescriptorPool, +impl GrancClient { + pub(crate) fn new(state: T) -> Self { + Self { state } + } } -/// The state for a client that uses Server Reflection for schema resolution. +/// State: Connected to server, Schema resolved from Server Reflection. #[derive(Debug, Clone)] -pub struct WithServerReflection { +pub struct Online { reflection_client: ReflectionClient, grpc_client: GrpcClient, } -/// A request object encapsulating all necessary information to perform a dynamic gRPC call. +/// State: Connected to server, Schema resolved from local FileDescriptor. #[derive(Debug, Clone)] -pub struct DynamicRequest { - /// The JSON body of the request. - /// - For Unary/ServerStreaming: An Object `{}`. - /// - For ClientStreaming/Bidirectional: An Array of Objects `[{}]`. - pub body: serde_json::Value, - /// Custom gRPC metadata (headers) to attach to the request. - pub headers: Vec<(String, String)>, - /// The fully qualified name of the service (e.g., `my.package.Service`). - pub service: String, - /// The name of the method to call (e.g., `SayHello`). - pub method: String, +pub struct OnlineWithoutReflection { + grpc_client: GrpcClient, + pool: DescriptorPool, } -/// The result of a dynamic gRPC call. -#[derive(Debug, Clone)] -pub enum DynamicResponse { - /// A single response message (for Unary and Client Streaming calls). - Unary(Result), - /// A stream of response messages (for Server Streaming and Bidirectional calls). - Streaming(Result>, tonic::Status>), -} - -/// A generic wrapper for different types of Protobuf descriptors. -/// -/// This enum allows the client to return a single type when resolving symbols, -/// regardless of whether the symbol points to a Service, a Message, or an Enum. -#[derive(Debug, Clone)] -pub enum Descriptor { - MessageDescriptor(MessageDescriptor), - ServiceDescriptor(ServiceDescriptor), - EnumDescriptor(EnumDescriptor), -} - -impl Descriptor { - /// Returns the inner [`MessageDescriptor`] if this variant is `MessageDescriptor`. - pub fn message_descriptor(&self) -> Option<&MessageDescriptor> { - match self { - Descriptor::MessageDescriptor(message_descriptor) => Some(message_descriptor), - _ => None, - } - } - - /// Returns the inner [`ServiceDescriptor`] if this variant is `ServiceDescriptor`. - pub fn service_descriptor(&self) -> Option<&ServiceDescriptor> { - match self { - Descriptor::ServiceDescriptor(service_descriptor) => Some(service_descriptor), - _ => None, - } - } - - /// Returns the inner [`EnumDescriptor`] if this variant is `EnumDescriptor`. - pub fn enum_descriptor(&self) -> Option<&EnumDescriptor> { - match self { - Descriptor::EnumDescriptor(enum_descriptor) => Some(enum_descriptor), - _ => None, - } +impl OnlineWithoutReflection { + pub(crate) fn new(grpc_client: GrpcClient, pool: DescriptorPool) -> Self { + Self { pool, grpc_client } + } +} + +/// State: Disconnected, Schema resolved from local FileDescriptor. +#[derive(Debug, Clone)] +pub struct Offline { + pool: DescriptorPool, +} + +impl Offline { + pub(crate) fn new(pool: DescriptorPool) -> Self { + Self { pool } + } +} + +pub trait OfflineReflectionState { + fn descriptor_pool(&self) -> &DescriptorPool; +} + +impl OfflineReflectionState for Offline { + fn descriptor_pool(&self) -> &DescriptorPool { + &self.pool + } +} + +impl OfflineReflectionState for OnlineWithoutReflection { + fn descriptor_pool(&self) -> &DescriptorPool { + &self.pool } } diff --git a/granc-core/src/client/offline.rs b/granc-core/src/client/offline.rs new file mode 100644 index 0000000..ba2f41c --- /dev/null +++ b/granc-core/src/client/offline.rs @@ -0,0 +1,73 @@ +//! # Client State: Offline +//! +//! This module defines the `GrancClient` behavior when it is using a local, in-memory +//! `DescriptorPool` but is **not connected** to any gRPC server. +//! +//! In this state, the client is strictly limited to introspection tasks. +use super::{GrancClient, Offline}; +use crate::client::{OfflineReflectionState, types::Descriptor}; +use prost_reflect::{DescriptorError, DescriptorPool}; + +impl GrancClient { + /// Creates a new `GrancClient` in the Offline state using a raw byte buffer + /// containing a `FileDescriptorSet`. + /// + /// This client starts in a **disconnected** state. It can be used to inspect the + /// provided schema but cannot make network requests. + /// + /// # Arguments + /// + /// * `file_descriptor` - A vector of bytes containing the encoded `FileDescriptorSet`. + /// + /// # Returns + /// + /// * `Ok(GrancClient)` - The initialized offline client. + /// * `Err(DescriptorError)` - If the bytes are not a valid descriptor set. + pub fn offline(file_descriptor: Vec) -> Result { + let pool = DescriptorPool::decode(file_descriptor.as_slice())?; + Ok(GrancClient::new(Offline::new(pool))) + } +} + +impl GrancClient +where + T: OfflineReflectionState, +{ + /// Lists all services defined in the local `DescriptorPool`. + /// + /// # Returns + /// + /// A list of fully qualified service names (e.g. `helloworld.Greeter`). + pub fn list_services(&self) -> Vec { + self.state + .descriptor_pool() + .services() + .map(|s| s.full_name().to_string()) + .collect() + } + + /// Looks up a specific symbol in the local `DescriptorPool`. + /// + /// # Arguments + /// + /// * `symbol` - The fully qualified name (Service, Message, or Enum). + /// + /// # Returns + /// + /// * `Some(Descriptor)` - The resolved descriptor if found. + /// * `None` - If the symbol does not exist in the pool. + pub fn get_descriptor_by_symbol(&self, symbol: &str) -> Option { + let pool = self.state.descriptor_pool(); + + if let Some(descriptor) = pool.get_service_by_name(symbol) { + return Some(Descriptor::ServiceDescriptor(descriptor)); + } + if let Some(descriptor) = pool.get_message_by_name(symbol) { + return Some(Descriptor::MessageDescriptor(descriptor)); + } + if let Some(descriptor) = pool.get_enum_by_name(symbol) { + return Some(Descriptor::EnumDescriptor(descriptor)); + } + None + } +} diff --git a/granc-core/src/client/with_server_reflection.rs b/granc-core/src/client/online.rs similarity index 50% rename from granc-core/src/client/with_server_reflection.rs rename to granc-core/src/client/online.rs index 1c1ffc5..793fc86 100644 --- a/granc-core/src/client/with_server_reflection.rs +++ b/granc-core/src/client/online.rs @@ -1,45 +1,45 @@ -//! # Client State: Server Reflection +//! # Client State: Online (With Server Reflection) //! -//! This module defines the `GrancClient` behavior when it is using the server's reflection service -//! to resolve schemas. +//! This module defines the `GrancClient` behavior when it is connected to a server +//! and using Server Reflection for schema resolution. use super::{ - Descriptor, DynamicRequest, DynamicResponse, GrancClient, WithFileDescriptor, - WithServerReflection, + Descriptor, DynamicRequest, DynamicResponse, GrancClient, Online, OnlineWithoutReflection, }; use crate::{ BoxError, + client::Offline, grpc::client::GrpcClient, reflection::client::{ReflectionClient, ReflectionResolveError}, }; use http_body::Body as HttpBody; -use prost_reflect::DescriptorError; -use prost_reflect::DescriptorPool; +use prost_reflect::{DescriptorError, DescriptorPool}; use std::fmt::Debug; use tonic::{ Code, transport::{Channel, Endpoint}, }; +/// Errors that can occur when connecting to a gRPC server. #[derive(Debug, thiserror::Error)] pub enum ClientConnectError { - #[error("Invalid URL '{0}': {1}")] - InvalidUrl(String, #[source] tonic::transport::Error), + #[error("Invalid URI '{0}': {1}")] + InvalidUri(String, #[source] tonic::transport::Error), #[error("Failed to connect to '{0}': {1}")] ConnectionFailed(String, #[source] tonic::transport::Error), } +/// Errors that can occur during a dynamic call in Online mode. #[derive(Debug, thiserror::Error)] pub enum DynamicCallError { #[error("Reflection resolution failed: '{0}'")] ReflectionResolve(#[from] ReflectionResolveError), - #[error("Failed to decode file descriptor set: '{0}'")] DescriptorError(#[from] DescriptorError), - #[error(transparent)] - DynamicCallError(#[from] super::with_file_descriptor::DynamicCallError), + DynamicCallError(#[from] super::online_without_reflection::DynamicCallError), } +/// Errors that can occur when looking up a descriptor in Online mode. #[derive(Debug, thiserror::Error)] pub enum GetDescriptorError { #[error("Reflection resolution failed: '{0}'")] @@ -50,60 +50,66 @@ pub enum GetDescriptorError { NotFound(String), } -impl GrancClient> { - /// Connects to a gRPC server at the specified address. +impl GrancClient> { + /// Connects to a gRPC server and initializes the client in the `Online` state. /// - /// This initializes the client in the **Reflection** state. It establishes a TCP connection - /// but does not yet perform any reflection calls. + /// This is the entry point for interacting with a server. By default, the client assumes + /// the server supports the gRPC Server Reflection Protocol. /// /// # Arguments /// - /// * `addr` - The URI of the server (e.g., `http://localhost:50051`). + /// * `addr` - The server URI (e.g., `http://localhost:50051`). /// /// # Returns /// - /// * `Ok(GrancClient)` - The connected client ready to use reflection. - /// * `Err(ClientConnectError)` - If the URL is invalid or the connection cannot be established. + /// * `Ok(GrancClient)` - A connected client ready to make dynamic requests via reflection. + /// * `Err(ClientConnectError)` - If the URI is invalid or the TCP connection cannot be established. pub async fn connect(addr: &str) -> Result { let endpoint = Endpoint::new(addr.to_string()) - .map_err(|e| ClientConnectError::InvalidUrl(addr.to_string(), e))?; + .map_err(|e| ClientConnectError::InvalidUri(addr.to_string(), e))?; let channel = endpoint .connect() .await .map_err(|e| ClientConnectError::ConnectionFailed(addr.to_string(), e))?; - Ok(Self::from_service(channel)) + Ok(GrancClient::from(channel)) } } -impl GrancClient> +impl From for GrancClient> where S: tonic::client::GrpcService + Clone, S::ResponseBody: HttpBody + Send + 'static, ::Error: Into + Send, { - /// Creates a new `GrancClient` wrapping an existing Tonic service (e.g., a `Channel` or `InterceptedService`). - /// - /// # Arguments - /// - /// * `service` - The generic gRPC service implementation to use for transport. - pub fn from_service(service: S) -> Self { + fn from(service: S) -> Self { let reflection_client = ReflectionClient::new(service.clone()); let grpc_client = GrpcClient::new(service); - Self { - state: WithServerReflection { + state: Online { reflection_client, grpc_client, }, } } +} - /// Transitions the client to the **File Descriptor** state. +impl GrancClient> +where + S: tonic::client::GrpcService + Clone, + S::ResponseBody: HttpBody + Send + 'static, + ::Error: Into + Send, +{ + /// Transitions the client to the **OnlineWithoutReflection** state by loading a local descriptor. /// - /// This method consumes the current client and returns a new client that uses the provided - /// binary `FileDescriptorSet` for all schema lookups, disabling server reflection. + /// This methods consumes the current client and returns a new one that: + /// 1. Retains the existing network connection. + /// 2. Disables Server Reflection lookups. + /// 3. Uses the provided `FileDescriptorSet` for all future schema resolutions. + /// + /// This is useful when the server does not support reflection or when you want to enforce + /// a specific schema version. /// /// # Arguments /// @@ -111,30 +117,35 @@ where /// /// # Returns /// - /// * `Ok(GrancClient)` - The new client state. - /// * `Err(DescriptorError)` - If the provided bytes could not be decoded into a valid `DescriptorPool`. + /// * `Ok(GrancClient)` - The client in the new state. + /// * `Err(DescriptorError)` - If the provided bytes cannot be decoded into a valid descriptor pool. pub fn with_file_descriptor( self, file_descriptor: Vec, - ) -> Result>, DescriptorError> { + ) -> Result>, DescriptorError> { let pool = DescriptorPool::decode(file_descriptor.as_slice())?; - Ok(GrancClient::new(self.state.grpc_client, pool)) + + Ok(GrancClient::new(OnlineWithoutReflection::new( + self.state.grpc_client, + pool, + ))) } - /// Lists all services exposed by the server by querying the reflection endpoint. + /// Lists all services exposed by the server using the Reflection Protocol. /// /// # Returns /// - /// * `Ok(Vec)` - A list of fully qualified service names (e.g. `helloworld.Greeter`). - /// * `Err(ReflectionResolveError)` - If the reflection call fails or the server doesn't support reflection. + /// * `Ok(Vec)` - A list of fully qualified service names (e.g., `helloworld.Greeter`). + /// * `Err(ReflectionResolveError)` - If the reflection call fails, the stream is closed unexpectedly, + /// or the server returns an error code. pub async fn list_services(&mut self) -> Result, ReflectionResolveError> { self.state.reflection_client.list_services().await } - /// Resolves and fetches the descriptor for a specific symbol using reflection. + /// Resolves and fetches the descriptor for a specific symbol using Reflection. /// - /// This will recursively fetch the file defining the symbol and all its dependencies - /// from the server. + /// This will query the server for the symbol, fetch the defining file, and recursively fetch + /// all imported dependencies to build a complete `Descriptor`. /// /// # Arguments /// @@ -143,7 +154,7 @@ where /// # Returns /// /// * `Ok(Descriptor)` - The resolved descriptor wrapper. - /// * `Err(GetDescriptorError)` - If the symbol is not found or reflection fails. + /// * `Err(GetDescriptorError)` - If the symbol is not found on the server or the reflection request fails. pub async fn get_descriptor_by_symbol( &mut self, symbol: &str, @@ -163,29 +174,34 @@ where })?; let pool = DescriptorPool::from_file_descriptor_set(fd_set)?; - - let mut client = - GrancClient::>::new(self.state.grpc_client.clone(), pool); + let client = GrancClient::new(Offline::new(pool)); client .get_descriptor_by_symbol(symbol) .ok_or_else(|| GetDescriptorError::NotFound(symbol.to_string())) } - /// Executes a dynamic gRPC request using reflection. - /// - /// 1. It fetches the schema for the requested `service` via reflection. - /// 2. It builds a temporary `WithFileDescriptor` client using that schema. - /// 3. It delegates the call to that client. + /// Executes a dynamic gRPC request using Server Reflection for schema resolution. /// /// # Arguments /// - /// * `request` - The [`DynamicRequest`] containing the method to call and the JSON body. + /// * `request` - A [`DynamicRequest`] struct containing: + /// - `service`: The fully qualified name of the service (e.g., `my.package.MyService`). + /// - `method`: The name of the method to call (e.g., `MyMethod`). + /// - `body`: The JSON payload (Object for Unary/ServerStreaming, Array for Client/BiDi Streaming). + /// - `headers`: Optional gRPC metadata/headers. /// /// # Returns /// - /// * `Ok(DynamicResponse)` - The result of the gRPC call (Unary or Streaming). - /// * `Err(DynamicCallError)` - If schema resolution, validation, or the network call fails. + /// * `Ok(DynamicResponse)` - The result of the call, which can be: + /// - [`DynamicResponse::Unary`]: For Unary and Client Streaming calls (single response). + /// - [`DynamicResponse::Streaming`]: For Server Streaming and Bidirectional calls (stream of responses). + /// * `Err(DynamicCallError)` - If an error occurs during: + /// - Reflection resolution (e.g., Service not found). + /// - Schema parsing. + /// - Request serialization (JSON to Proto). + /// - Network transport. + /// - Response deserialization. pub async fn dynamic( &mut self, request: DynamicRequest, @@ -198,8 +214,10 @@ where let pool = DescriptorPool::from_file_descriptor_set(fd_set)?; - let mut client = - GrancClient::>::new(self.state.grpc_client.clone(), pool); + let mut client = GrancClient::new(OnlineWithoutReflection::new( + self.state.grpc_client.clone(), + pool, + )); Ok(client.dynamic(request).await?) } diff --git a/granc-core/src/client/online_without_reflection.rs b/granc-core/src/client/online_without_reflection.rs new file mode 100644 index 0000000..d251bd0 --- /dev/null +++ b/granc-core/src/client/online_without_reflection.rs @@ -0,0 +1,116 @@ +//! # Client State: Online Without Reflection +//! +//! This module defines the `GrancClient` behavior when it is connected to a server +//! but uses a local, in-memory `DescriptorPool` (Static schema) to resolve messages. +use super::{DynamicRequest, DynamicResponse, GrancClient, OnlineWithoutReflection}; +use crate::{BoxError, client::OfflineReflectionState, grpc::client::GrpcRequestError}; +use futures_util::{Stream, StreamExt}; +use http_body::Body as HttpBody; +use std::fmt::Debug; + +/// Errors that can occur during a dynamic call in OnlineWithoutReflection mode. +#[derive(Debug, thiserror::Error)] +pub enum DynamicCallError { + #[error("Invalid input: '{0}'")] + InvalidInput(String), + #[error("Service '{0}' not found")] + ServiceNotFound(String), + #[error("Method '{0}' not found")] + MethodNotFound(String), + #[error("gRPC client request error: '{0}'")] + GrpcRequestError(#[from] GrpcRequestError), +} + +impl GrancClient> +where + S: tonic::client::GrpcService + Clone, + S::ResponseBody: HttpBody + Send + 'static, + ::Error: Into + Send, +{ + /// Executes a dynamic gRPC request using the locally loaded `FileDescriptorSet`. + /// + /// Unlike the `Online` state, this method does **not** make any calls to the server's reflection endpoint. + /// It relies entirely on the local `DescriptorPool` provided during state transition (see [`super::Online::with_file_descriptor`]). + /// + /// # Arguments + /// + /// * `request` - A [`DynamicRequest`] struct containing: + /// - `service`: The fully qualified name of the service (e.g., `my.package.MyService`). + /// - `method`: The name of the method to call (e.g., `MyMethod`). + /// - `body`: The JSON payload. + /// - `headers`: Optional gRPC metadata. + /// + /// # Returns + /// + /// * `Ok(DynamicResponse)` - The result of the call (Unary or Streaming). + /// * `Err(DynamicCallError)` - If validation fails or the network call errors. Specific errors include: + /// - [`DynamicCallError::ServiceNotFound`]: The service is not present in the local descriptor. + /// - [`DynamicCallError::MethodNotFound`]: The method does not exist in the service. + /// - [`DynamicCallError::InvalidInput`]: The JSON body structure is invalid for the streaming mode (e.g. object provided for streaming call). + /// - [`DynamicCallError::GrpcRequestError`]: Transport-level errors (connection failed, timeout, etc). + pub async fn dynamic( + &mut self, + request: DynamicRequest, + ) -> Result { + let method = self + .state + .descriptor_pool() + .get_service_by_name(&request.service) + .ok_or_else(|| DynamicCallError::ServiceNotFound(request.service.clone()))? + .methods() + .find(|m| m.name() == request.method) + .ok_or_else(|| DynamicCallError::MethodNotFound(request.method.clone()))?; + + match (method.is_client_streaming(), method.is_server_streaming()) { + (false, false) => { + let result = self + .state + .grpc_client + .unary(method, request.body, request.headers) + .await?; + Ok(DynamicResponse::Unary(result)) + } + (false, true) => match self + .state + .grpc_client + .server_streaming(method, request.body, request.headers) + .await? + { + Ok(stream) => Ok(DynamicResponse::Streaming(Ok(stream.collect().await))), + Err(status) => Ok(DynamicResponse::Streaming(Err(status))), + }, + (true, false) => { + let input_stream = + json_array_to_stream(request.body).map_err(DynamicCallError::InvalidInput)?; + let result = self + .state + .grpc_client + .client_streaming(method, input_stream, request.headers) + .await?; + Ok(DynamicResponse::Unary(result)) + } + (true, true) => { + let input_stream = + json_array_to_stream(request.body).map_err(DynamicCallError::InvalidInput)?; + match self + .state + .grpc_client + .bidirectional_streaming(method, input_stream, request.headers) + .await? + { + Ok(stream) => Ok(DynamicResponse::Streaming(Ok(stream.collect().await))), + Err(status) => Ok(DynamicResponse::Streaming(Err(status))), + } + } + } + } +} + +fn json_array_to_stream( + json: serde_json::Value, +) -> Result + Send + 'static, String> { + match json { + serde_json::Value::Array(items) => Ok(tokio_stream::iter(items)), + _ => Err("Client streaming requires a JSON Array body".to_string()), + } +} diff --git a/granc-core/src/client/types.rs b/granc-core/src/client/types.rs new file mode 100644 index 0000000..74381c7 --- /dev/null +++ b/granc-core/src/client/types.rs @@ -0,0 +1,63 @@ +use prost_reflect::{EnumDescriptor, MessageDescriptor, ServiceDescriptor}; +use std::fmt::Debug; + +/// A request object encapsulating all necessary information to perform a dynamic gRPC call. +#[derive(Debug, Clone)] +pub struct DynamicRequest { + /// The JSON body of the request. + /// - For Unary/ServerStreaming: An Object `{}`. + /// - For ClientStreaming/Bidirectional: An Array of Objects `[{}]`. + pub body: serde_json::Value, + /// Custom gRPC metadata (headers) to attach to the request. + pub headers: Vec<(String, String)>, + /// The fully qualified name of the service (e.g., `my.package.Service`). + pub service: String, + /// The name of the method to call (e.g., `SayHello`). + pub method: String, +} + +/// The result of a dynamic gRPC call. +#[derive(Debug, Clone)] +pub enum DynamicResponse { + /// A single response message (for Unary and Client Streaming calls). + Unary(Result), + /// A stream of response messages (for Server Streaming and Bidirectional calls). + Streaming(Result>, tonic::Status>), +} + +/// A generic wrapper for different types of Protobuf descriptors. +/// +/// This enum allows the client to return a single type when resolving symbols, +/// regardless of whether the symbol points to a Service, a Message, or an Enum. +#[derive(Debug, Clone)] +pub enum Descriptor { + MessageDescriptor(MessageDescriptor), + ServiceDescriptor(ServiceDescriptor), + EnumDescriptor(EnumDescriptor), +} + +impl Descriptor { + /// Returns the inner [`MessageDescriptor`] if this variant is `MessageDescriptor`. + pub fn message_descriptor(&self) -> Option<&MessageDescriptor> { + match self { + Descriptor::MessageDescriptor(d) => Some(d), + _ => None, + } + } + + /// Returns the inner [`ServiceDescriptor`] if this variant is `ServiceDescriptor`. + pub fn service_descriptor(&self) -> Option<&ServiceDescriptor> { + match self { + Descriptor::ServiceDescriptor(d) => Some(d), + _ => None, + } + } + + /// Returns the inner [`EnumDescriptor`] if this variant is `EnumDescriptor`. + pub fn enum_descriptor(&self) -> Option<&EnumDescriptor> { + match self { + Descriptor::EnumDescriptor(d) => Some(d), + _ => None, + } + } +} diff --git a/granc-core/src/client/with_file_descriptor.rs b/granc-core/src/client/with_file_descriptor.rs deleted file mode 100644 index fab2858..0000000 --- a/granc-core/src/client/with_file_descriptor.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! # Client State: File Descriptor -//! -//! This module defines the `GrancClient` behavior when it is using a local, in-memory -//! `DescriptorPool` (loaded from a file) to resolve schemas. -//! -//! In this state, the client does **not** use server reflection for schema lookup. -use super::WithFileDescriptor; -use super::{Descriptor, DynamicRequest, DynamicResponse, GrancClient}; -use crate::{ - BoxError, - grpc::client::{GrpcClient, GrpcRequestError}, -}; -use futures_util::Stream; -use futures_util::StreamExt; -use http_body::Body as HttpBody; -use prost_reflect::DescriptorPool; -use std::fmt::Debug; - -#[derive(Debug, thiserror::Error)] -pub enum DynamicCallError { - #[error("Invalid input: '{0}'")] - InvalidInput(String), - - #[error("Service '{0}' not found")] - ServiceNotFound(String), - - #[error("Method '{0}' not found")] - MethodNotFound(String), - - #[error("gRPC client request error: '{0}'")] - GrpcRequestError(#[from] GrpcRequestError), -} - -impl GrancClient> -where - S: Clone, -{ - pub(crate) fn new(grpc_client: GrpcClient, pool: DescriptorPool) -> Self { - Self { - state: WithFileDescriptor { grpc_client, pool }, - } - } -} - -impl GrancClient> -where - S: tonic::client::GrpcService + Clone, - S::ResponseBody: HttpBody + Send + 'static, - ::Error: Into + Send, -{ - /// Lists all services defined in the loaded `DescriptorPool`. - /// - /// Unlike the reflection client, this is a synchronous operation that returns - /// immediately from memory. - /// - /// # Returns - /// - /// A list of fully qualified service names (e.g. `helloworld.Greeter`). - pub fn list_services(&mut self) -> Vec { - self.state - .pool - .services() - .map(|s| s.full_name().to_string()) - .collect() - } - - /// Looks up a specific symbol in the loaded `DescriptorPool`. - /// - /// # Arguments - /// - /// * `symbol` - The fully qualified name of the symbol (Service, Message, or Enum). - /// - /// # Returns - /// - /// * `Some(Descriptor)` - The resolved descriptor if found. - /// * `None` - If the symbol does not exist in the pool. - pub fn get_descriptor_by_symbol(&mut self, symbol: &str) -> Option { - let pool = &self.state.pool; - - if let Some(descriptor) = pool.get_service_by_name(symbol) { - return Some(Descriptor::ServiceDescriptor(descriptor)); - } - - if let Some(descriptor) = pool.get_message_by_name(symbol) { - return Some(Descriptor::MessageDescriptor(descriptor)); - } - - if let Some(descriptor) = pool.get_enum_by_name(symbol) { - return Some(Descriptor::EnumDescriptor(descriptor)); - } - - None - } - - /// Executes a dynamic gRPC request using the loaded `DescriptorPool`. - /// - /// It looks up the service and method definitions in the local pool, validates the JSON, and sends the request. - /// - /// # Arguments - /// - /// * `request` - The [`DynamicRequest`] containing the method to call and the JSON body. - /// - /// # Returns - /// - /// * `Ok(DynamicResponse)` - The result of the gRPC call. - /// * `Err(DynamicCallError)` - If the service/method is not in the pool, the JSON is invalid, or the call fails. - pub async fn dynamic( - &mut self, - request: DynamicRequest, - ) -> Result { - let method = self - .state - .pool - .get_service_by_name(&request.service) - .ok_or_else(|| DynamicCallError::ServiceNotFound(request.service))? - .methods() - .find(|m| m.name() == request.method) - .ok_or_else(|| DynamicCallError::MethodNotFound(request.method))?; - - match (method.is_client_streaming(), method.is_server_streaming()) { - (false, false) => { - let result = self - .state - .grpc_client - .unary(method, request.body, request.headers) - .await?; - Ok(DynamicResponse::Unary(result)) - } - - (false, true) => { - match self - .state - .grpc_client - .server_streaming(method, request.body, request.headers) - .await? - { - Ok(stream) => Ok(DynamicResponse::Streaming(Ok(stream.collect().await))), - Err(status) => Ok(DynamicResponse::Streaming(Err(status))), - } - } - (true, false) => { - let input_stream = - json_array_to_stream(request.body).map_err(DynamicCallError::InvalidInput)?; - let result = self - .state - .grpc_client - .client_streaming(method, input_stream, request.headers) - .await?; - Ok(DynamicResponse::Unary(result)) - } - - (true, true) => { - let input_stream = - json_array_to_stream(request.body).map_err(DynamicCallError::InvalidInput)?; - match self - .state - .grpc_client - .bidirectional_streaming(method, input_stream, request.headers) - .await? - { - Ok(stream) => Ok(DynamicResponse::Streaming(Ok(stream.collect().await))), - Err(status) => Ok(DynamicResponse::Streaming(Err(status))), - } - } - } - } -} - -/// Helper to convert a JSON Array into a Stream of JSON Values. -/// Required for Client and Bidirectional streaming. -fn json_array_to_stream( - json: serde_json::Value, -) -> Result + Send + 'static, String> { - match json { - serde_json::Value::Array(items) => Ok(tokio_stream::iter(items)), - _ => Err("Client streaming requires a JSON Array body".to_string()), - } -} diff --git a/granc-core/tests/granc_client_offline_test.rs b/granc-core/tests/granc_client_offline_test.rs new file mode 100644 index 0000000..8ce71d6 --- /dev/null +++ b/granc-core/tests/granc_client_offline_test.rs @@ -0,0 +1,49 @@ +use echo_service::FILE_DESCRIPTOR_SET; +use granc_core::client::{Descriptor, GrancClient}; + +#[test] +fn test_offline_list_services() { + let client = GrancClient::offline(FILE_DESCRIPTOR_SET.to_vec()) + .expect("Failed to load file descriptor set"); + + let mut services = client.list_services(); + services.sort(); + + assert_eq!(services.as_slice(), ["echo.EchoService"]); +} + +#[test] +fn test_offline_describe_descriptors() { + let client = GrancClient::offline(FILE_DESCRIPTOR_SET.to_vec()) + .expect("Failed to load file descriptor set"); + + // 1. Success: Service + let desc = client + .get_descriptor_by_symbol("echo.EchoService") + .expect("Service not found"); + + assert!(matches!( + desc, + Descriptor::ServiceDescriptor(s) if s.name() == "EchoService" + )); + + // 2. Success: Message + let desc = client + .get_descriptor_by_symbol("echo.EchoRequest") + .expect("Message not found"); + + assert!(matches!( + desc, + Descriptor::MessageDescriptor(m) if m.name() == "EchoRequest" + )); + + // 3. Error: Symbol Not Found + let desc = client.get_descriptor_by_symbol("echo.Ghost"); + assert!(desc.is_none()); +} + +#[test] +fn test_offline_creation_error() { + let result = GrancClient::offline(vec![0, 1, 2, 3]); + assert!(result.is_err()); +} diff --git a/granc-core/tests/granc_client_reflection_test.rs b/granc-core/tests/granc_client_online_test.rs similarity index 52% rename from granc-core/tests/granc_client_reflection_test.rs rename to granc-core/tests/granc_client_online_test.rs index 1621cac..4c74117 100644 --- a/granc-core/tests/granc_client_reflection_test.rs +++ b/granc-core/tests/granc_client_online_test.rs @@ -1,16 +1,14 @@ use echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET}; use echo_service_impl::EchoServiceImpl; -use granc_core::client::{ - Descriptor, DynamicRequest, DynamicResponse, GrancClient, WithServerReflection, - with_file_descriptor, with_server_reflection, -}; +use granc_core::client::{DynamicRequest, DynamicResponse, GrancClient, Online, online}; use granc_core::reflection::client::ReflectionResolveError; use tonic::Code; use tonic::service::Routes; mod echo_service_impl; -async fn setup_client() -> GrancClient> { +async fn setup_client() -> GrancClient> { + // Enable Reflection let reflection_service = tonic_reflection::server::Builder::configure() .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET) .build_v1() @@ -20,15 +18,13 @@ async fn setup_client() -> GrancClient> { let service = Routes::new(reflection_service).add_service(echo_service); - GrancClient::from_service(service) + GrancClient::from(service) } #[tokio::test] async fn test_reflection_list_services() { let mut client = setup_client().await; - let mut services = client.list_services().await.unwrap(); - services.sort(); assert_eq!( @@ -38,63 +34,24 @@ async fn test_reflection_list_services() { } #[tokio::test] -async fn test_reflection_describe_descriptors() { +async fn test_reflection_unary_success() { let mut client = setup_client().await; - let desc = client - .get_descriptor_by_symbol("echo.EchoService") - .await - .unwrap(); - - assert!(matches!( - desc, - Descriptor::ServiceDescriptor(s) - if s.name() == "EchoService" - && s.methods().any(|m| m.name() == "UnaryEcho") - )); - - let desc = client - .get_descriptor_by_symbol("echo.EchoRequest") - .await - .unwrap(); - - assert!(matches!( - desc, - Descriptor::MessageDescriptor(m) - if m.name() == "EchoRequest" - && m.fields().any(|f| f.name() == "message") - )); -} - -#[tokio::test] -async fn test_reflection_describe_error() { - let mut client = setup_client().await; - - let result = client.get_descriptor_by_symbol("echo.Ghost").await; - - assert!(matches!( - result, - Err(with_server_reflection::GetDescriptorError::NotFound(name)) if name == "echo.Ghost" - )); -} - -#[tokio::test] -async fn test_reflection_dynamic_calls() { - let mut client = setup_client().await; - - // Unary Call let req = DynamicRequest { service: "echo.EchoService".to_string(), method: "UnaryEcho".to_string(), - body: serde_json::json!({ "message": "hello" }), + body: serde_json::json!({ "message": "reflection" }), headers: vec![], }; let res = client.dynamic(req).await.unwrap(); + assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "reflection")); +} - assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "hello")); +#[tokio::test] +async fn test_reflection_server_streaming_success() { + let mut client = setup_client().await; - // Server Streaming let req = DynamicRequest { service: "echo.EchoService".to_string(), method: "ServerStreamingEcho".to_string(), @@ -104,46 +61,38 @@ async fn test_reflection_dynamic_calls() { let res = client.dynamic(req).await.unwrap(); - assert!(matches!(res, DynamicResponse::Streaming(Ok(stream)) if stream.len() == 3)); - - // Client Streaming - let req = DynamicRequest { - service: "echo.EchoService".to_string(), - method: "ClientStreamingEcho".to_string(), - body: serde_json::json!([ - { "message": "A" }, - { "message": "B" } - ]), - headers: vec![], - }; - - let res = client.dynamic(req).await.unwrap(); - - assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "AB")); - - // Bidirectional Streaming - let req = DynamicRequest { - service: "echo.EchoService".to_string(), - method: "BidirectionalEcho".to_string(), - body: serde_json::json!([ - { "message": "Ping" } - ]), - headers: vec![], - }; - let res = client.dynamic(req).await.unwrap(); - - assert!(matches!(res, - DynamicResponse::Streaming(Ok(stream)) - if stream.len() == 1 - && stream[0].as_ref().unwrap()["message"] == "echo: Ping" - )); + match res { + DynamicResponse::Streaming(Ok(stream)) => { + assert_eq!(stream.len(), 3); + assert_eq!(stream[0].as_ref().unwrap()["message"], "stream - seq 0"); + assert_eq!(stream[1].as_ref().unwrap()["message"], "stream - seq 1"); + assert_eq!(stream[2].as_ref().unwrap()["message"], "stream - seq 2"); + } + _ => panic!("Expected Streaming response"), + } } #[tokio::test] -async fn test_reflection_dynamic_error_cases() { +async fn test_reflection_client_streaming_success() { let mut client = setup_client().await; - // Invalid Service Name + let req = DynamicRequest { + service: "echo.EchoService".to_string(), + method: "ClientStreamingEcho".to_string(), + body: serde_json::json!([{ "message": "A" }, { "message": "B" }]), + headers: vec![], + }; + + let res = client.dynamic(req).await.unwrap(); + assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "AB")); +} + +#[tokio::test] +async fn test_reflection_service_not_found() { + let mut client = setup_client().await; + + // Requesting a service that doesn't exist on the server. + // This fails during the Reflection Lookup phase. let req = DynamicRequest { service: "echo.GhostService".to_string(), method: "UnaryEcho".to_string(), @@ -155,12 +104,18 @@ async fn test_reflection_dynamic_error_cases() { assert!(matches!( result, - Err(with_server_reflection::DynamicCallError::ReflectionResolve( + Err(online::DynamicCallError::ReflectionResolve( ReflectionResolveError::ServerStreamFailure(status) )) if status.code() == Code::NotFound )); +} - // Invalid Method Name +#[tokio::test] +async fn test_reflection_method_not_found() { + let mut client = setup_client().await; + + // The service exists, so reflection succeeds in fetching the schema. + // However, the schema does not contain "GhostMethod", so it fails locally before call. let req = DynamicRequest { service: "echo.EchoService".to_string(), method: "GhostMethod".to_string(), @@ -172,17 +127,21 @@ async fn test_reflection_dynamic_error_cases() { assert!(matches!( result, - Err(with_server_reflection::DynamicCallError::DynamicCallError( - with_file_descriptor::DynamicCallError::MethodNotFound(name) + Err(online::DynamicCallError::DynamicCallError( + granc_core::client::online_without_reflection::DynamicCallError::MethodNotFound(name) )) if name == "GhostMethod" )); +} - // Invalid JSON Structure (Streaming requires Array, Object provided) - // This triggers `DynamicCallError::InvalidInput` before the request is sent. +#[tokio::test] +async fn test_reflection_invalid_input_structure() { + let mut client = setup_client().await; + + // Client streaming requires Array. let req = DynamicRequest { service: "echo.EchoService".to_string(), method: "ClientStreamingEcho".to_string(), - body: serde_json::json!({ "message": "I should be an array" }), + body: serde_json::json!({ "msg": "not array" }), headers: vec![], }; @@ -190,18 +149,22 @@ async fn test_reflection_dynamic_error_cases() { assert!(matches!( result, - Err(with_server_reflection::DynamicCallError::DynamicCallError( - with_file_descriptor::DynamicCallError::InvalidInput(_) + Err(online::DynamicCallError::DynamicCallError( + granc_core::client::online_without_reflection::DynamicCallError::InvalidInput(_) )) )); +} - // Schema Mismatch (Unary) - // Passing a field that doesn't exist. This fails at encoding time inside the Codec. - // Tonic wraps encoding errors as Code::Internal. +#[tokio::test] +async fn test_reflection_schema_mismatch() { + let mut client = setup_client().await; + + // Field "wrong_field" does not exist in the protobuf definition. + // Should fail with InvalidArgument during encoding. let req = DynamicRequest { service: "echo.EchoService".to_string(), method: "UnaryEcho".to_string(), - body: serde_json::json!({ "non_existent_field": "oops" }), + body: serde_json::json!({ "wrong_field": "val" }), headers: vec![], }; diff --git a/granc-core/tests/granc_client_file_descriptor_test.rs b/granc-core/tests/granc_client_online_without_reflection_test.rs similarity index 53% rename from granc-core/tests/granc_client_file_descriptor_test.rs rename to granc-core/tests/granc_client_online_without_reflection_test.rs index cc11584..f4385de 100644 --- a/granc-core/tests/granc_client_file_descriptor_test.rs +++ b/granc-core/tests/granc_client_online_without_reflection_test.rs @@ -1,16 +1,16 @@ use echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET}; use echo_service_impl::EchoServiceImpl; use granc_core::client::{ - Descriptor, DynamicRequest, DynamicResponse, GrancClient, WithFileDescriptor, - with_file_descriptor, + DynamicRequest, DynamicResponse, GrancClient, OnlineWithoutReflection, + online_without_reflection, }; use tonic::Code; mod echo_service_impl; -fn setup_client() -> GrancClient>> { +fn setup_client() -> GrancClient>> { let service = EchoServiceServer::new(EchoServiceImpl); - let client_reflection = GrancClient::from_service(service); + let client_reflection = GrancClient::from(service); client_reflection .with_file_descriptor(FILE_DESCRIPTOR_SET.to_vec()) @@ -18,49 +18,9 @@ fn setup_client() -> GrancClient { + assert_eq!(stream.len(), 3); + assert_eq!(stream[0].as_ref().unwrap()["message"], "stream - seq 0"); + assert_eq!(stream[1].as_ref().unwrap()["message"], "stream - seq 1"); + assert_eq!(stream[2].as_ref().unwrap()["message"], "stream - seq 2"); + } + _ => panic!("Expected Streaming response"), + } +} + +#[tokio::test] +async fn test_dynamic_client_streaming_success() { + let mut client = setup_client(); - // Client Streaming let req = DynamicRequest { service: "echo.EchoService".to_string(), method: "ClientStreamingEcho".to_string(), + // Client streaming requires a JSON Array body: serde_json::json!([ { "message": "A" }, - { "message": "B" } + { "message": "B" }, + { "message": "C" } ]), headers: vec![], }; let res = client.dynamic(req).await.unwrap(); - assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "AB")); - - // Bidirectional Streaming - let req = DynamicRequest { - service: "echo.EchoService".to_string(), - method: "BidirectionalEcho".to_string(), - body: serde_json::json!([ - { "message": "Ping" } - ]), - headers: vec![], - }; - let res = client.dynamic(req).await.unwrap(); - - assert!(matches!(res, - DynamicResponse::Streaming(Ok(stream)) - if stream.len() == 1 - && stream[0].as_ref().unwrap()["message"] == "echo: Ping" + assert!(matches!( + res, + DynamicResponse::Unary(Ok(val)) if val["message"] == "ABC" )); } #[tokio::test] -async fn test_error_cases() { +async fn test_dynamic_bidirectional_streaming_success() { + let mut client = setup_client(); + + let req = DynamicRequest { + service: "echo.EchoService".to_string(), + method: "BidirectionalEcho".to_string(), + body: serde_json::json!([ + { "message": "Ping" }, + { "message": "Pong" } + ]), + headers: vec![], + }; + + let res = client.dynamic(req).await.unwrap(); + + match res { + DynamicResponse::Streaming(Ok(stream)) => { + assert_eq!(stream.len(), 2); + assert_eq!(stream[0].as_ref().unwrap()["message"], "echo: Ping"); + assert_eq!(stream[1].as_ref().unwrap()["message"], "echo: Pong"); + } + _ => panic!("Expected Streaming response"), + } +} + +#[tokio::test] +async fn test_error_service_not_found() { let mut client = setup_client(); - // Service Not Found let req = DynamicRequest { service: "echo.GhostService".to_string(), method: "UnaryEcho".to_string(), @@ -133,10 +125,14 @@ async fn test_error_cases() { assert!(matches!( result, - Err(with_file_descriptor::DynamicCallError::ServiceNotFound(name)) if name == "echo.GhostService" + Err(online_without_reflection::DynamicCallError::ServiceNotFound(name)) if name == "echo.GhostService" )); +} + +#[tokio::test] +async fn test_error_method_not_found() { + let mut client = setup_client(); - // Method Not Found let req = DynamicRequest { service: "echo.EchoService".to_string(), method: "GhostMethod".to_string(), @@ -148,10 +144,15 @@ async fn test_error_cases() { assert!(matches!( result, - Err(with_file_descriptor::DynamicCallError::MethodNotFound(name)) if name == "GhostMethod" + Err(online_without_reflection::DynamicCallError::MethodNotFound(name)) if name == "GhostMethod" )); +} - // Invalid JSON Structure (Streaming requires Array) +#[tokio::test] +async fn test_error_invalid_input_structure() { + let mut client = setup_client(); + + // Client streaming requires an Array, passing an Object should fail let req = DynamicRequest { service: "echo.EchoService".to_string(), method: "ClientStreamingEcho".to_string(), @@ -163,11 +164,16 @@ async fn test_error_cases() { assert!(matches!( result, - Err(with_file_descriptor::DynamicCallError::InvalidInput(_)) + Err(online_without_reflection::DynamicCallError::InvalidInput(_)) )); +} - // Schema Mismatch (Unary) - // Field mismatch causes encoding error -> Status::InvalidArgument +#[tokio::test] +async fn test_error_schema_mismatch() { + let mut client = setup_client(); + + // Passing a field ("unknown_field") that doesn't exist in the EchoRequest proto definition. + // The JsonCodec (in granc-core/src/grpc/codec.rs) maps this to Status::InvalidArgument. let req = DynamicRequest { service: "echo.EchoService".to_string(), method: "UnaryEcho".to_string(), @@ -177,10 +183,12 @@ async fn test_error_cases() { let result = client.dynamic(req).await; + // This error happens during encoding inside the Tonic stack, so it returns + // a successful Result containing an Err(Status). assert!(matches!( result, Ok(DynamicResponse::Unary(Err(status))) if status.code() == Code::Internal - && status.message().contains("JSON structure does not match") + && status.message().contains("JSON structure does not match Protobuf schema") )); } diff --git a/granc/Cargo.toml b/granc/Cargo.toml index 6e75c8d..052fd74 100644 --- a/granc/Cargo.toml +++ b/granc/Cargo.toml @@ -16,6 +16,6 @@ version = "0.6.0" [dependencies] clap = { version = "4.5.54", features = ["derive"] } colored = "3.1.1" -granc_core = "0.5.0" +granc_core = { path = "../granc-core" } serde_json = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/granc/src/cli.rs b/granc/src/cli.rs index fbecab6..29498f2 100644 --- a/granc/src/cli.rs +++ b/granc/src/cli.rs @@ -1,53 +1,100 @@ //! # CLI //! //! This module defines the command-line interface of `granc` using `clap`. -//! -//! It is responsible for parsing user input and performing validation (e.g., ensuring headers are `key:value`); +//! It enforces strict invariants for arguments using subcommands and argument groups. use std::path::PathBuf; -use clap::{Parser, Subcommand}; +use clap::{Args, Parser, Subcommand}; -#[derive(Parser)] +#[derive(Parser, Debug)] #[command(name = "granc", version, about = "Dynamic gRPC CLI")] pub struct Cli { - /// The server URL to connect to (e.g. http://localhost:50051) - pub url: String, - - /// Path to the descriptor set (.bin) - #[arg(long)] - pub file_descriptor_set: Option, - #[command(subcommand)] pub command: Commands, } -#[derive(Subcommand)] +#[derive(Subcommand, Debug)] pub enum Commands { - /// Perform a gRPC call to a server + /// Perform a gRPC call to a server. /// - /// This command connects to a gRPC server and executes a method using a JSON body. + /// Requires a server URI. Can optionally use a local file descriptor set. Call { /// Endpoint (package.Service/Method) #[arg(value_parser = parse_endpoint)] endpoint: (String, String), + + /// The server URI to connect to (e.g. http://localhost:50051) + #[arg(long, short = 'u')] + uri: String, + /// "JSON body (Object for Unary, Array for Streaming)" - #[arg(long, value_parser = parse_body)] + #[arg(long, short = 'b', value_parser = parse_body)] body: serde_json::Value, #[arg(short = 'H', long = "header", value_parser = parse_header)] headers: Vec<(String, String)>, + + /// Optional path to a file descriptor set (.bin) to use instead of reflection + #[arg(long, short = 'f')] + file_descriptor_set: Option, }, - /// List available services - List, + /// List available services. + /// + /// Requires EITHER a server URI (Reflection) OR a file descriptor set (Offline). + List { + #[command(flatten)] + source: SourceSelection, + }, - /// Describe a service, message or enum passing the full path + /// Describe a service, message or enum. + /// + /// Requires EITHER a server URI (Reflection) OR a file descriptor set (Offline). Describe { + #[command(flatten)] + source: SourceSelection, + /// Fully qualified name (e.g. my.package.Service) symbol: String, }, } +#[derive(Args, Debug)] +#[group(required = true, multiple = false)] // Enforces: Either URI OR FileDescriptorSet, never both. +pub struct SourceSelection { + /// The server URI to use for reflection-based introspection + #[arg(long, short = 'u')] + uri: Option, + + /// Path to the descriptor set (.bin) to use for offline introspection + #[arg(long, short = 'f')] + file_descriptor_set: Option, +} + +// The source where to resolve the proto schemas from. +// +// It can either be a URI (If the server supports server streaming) +// or a file (a `.bin` or `.pb` file generated with protoc) +pub enum Source { + Uri(String), + File(PathBuf), +} + +impl SourceSelection { + pub fn value(self) -> Source { + if let Some(uri) = self.uri { + Source::Uri(uri) + } else if let Some(path) = self.file_descriptor_set { + Source::File(path) + } else { + // This is unreachable because `clap` verifies the group requirements before we ever get here. + unreachable!( + "Clap ensures exactly one argument (uri or file) is present via #[group(required = true)]" + ) + } + } +} + fn parse_endpoint(value: &str) -> Result<(String, String), String> { let (service, method) = value.split_once('/').ok_or_else(|| { format!("Invalid endpoint format: '{value}'. Expected 'package.Service/Method'",) @@ -69,3 +116,222 @@ fn parse_header(s: &str) -> Result<(String, String), String> { fn parse_body(value: &str) -> Result { serde_json::from_str(value).map_err(|e| format!("Invalid JSON: {e}")) } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn test_call_command_reflection() { + let args = vec![ + "granc", + "call", + "helloworld.Greeter/SayHello", + "--uri", + "http://localhost:50051", + "--body", + r#"{"name": "Ferris"}"#, + ]; + + let cli = Cli::try_parse_from(&args).expect("Parsing failed"); + + match cli.command { + Commands::Call { + endpoint, + uri, + body, + file_descriptor_set, + .. + } => { + assert_eq!( + endpoint, + ("helloworld.Greeter".to_string(), "SayHello".to_string()) + ); + assert_eq!(uri, "http://localhost:50051"); + assert_eq!(body, serde_json::json!({"name": "Ferris"})); + assert!(file_descriptor_set.is_none()); + } + _ => panic!("Expected Call command"), + } + } + + #[test] + fn test_call_command_with_file_descriptor() { + let args = vec![ + "granc", + "call", + "helloworld.Greeter/SayHello", + "--uri", + "http://localhost:50051", + "--body", + r#"{"name": "Ferris"}"#, + "--file-descriptor-set", + "./descriptors.bin", + ]; + + let cli = Cli::try_parse_from(&args).expect("Parsing failed"); + + match cli.command { + Commands::Call { + file_descriptor_set, + .. + } => { + assert_eq!( + file_descriptor_set.unwrap().to_str().unwrap(), + "./descriptors.bin" + ); + } + _ => panic!("Expected Call command"), + } + } + + #[test] + fn test_call_command_short_flags() { + let args = vec![ + "granc", + "call", + "svc/mthd", + "-u", + "http://localhost:50051", + "-b", + "{}", + "-f", + "desc.bin", + "-H", + "auth:bearer", + ]; + + let cli = Cli::try_parse_from(&args).expect("Parsing failed"); + + match cli.command { + Commands::Call { + uri, + file_descriptor_set, + headers, + body, + .. + } => { + assert_eq!(uri, "http://localhost:50051"); + assert_eq!(file_descriptor_set.unwrap().to_str().unwrap(), "desc.bin"); + assert_eq!(body, serde_json::json!({})); + assert_eq!(headers[0], ("auth".to_string(), "bearer".to_string())); + } + _ => panic!("Expected Call command"), + } + } + + #[test] + fn test_list_command_reflection() { + let args = vec!["granc", "list", "--uri", "http://localhost:50051"]; + let cli = Cli::try_parse_from(&args).expect("Parsing failed"); + + match cli.command { + Commands::List { source } => { + assert_eq!(source.uri.unwrap(), "http://localhost:50051"); + assert!(source.file_descriptor_set.is_none()); + } + _ => panic!("Expected List command"), + } + } + + #[test] + fn test_list_command_offline() { + let args = vec!["granc", "list", "--file-descriptor-set", "desc.bin"]; + let cli = Cli::try_parse_from(&args).expect("Parsing failed"); + + match cli.command { + Commands::List { source } => { + assert_eq!( + source.file_descriptor_set.unwrap().to_str().unwrap(), + "desc.bin" + ); + assert!(source.uri.is_none()); + } + _ => panic!("Expected List command"), + } + } + + #[test] + fn test_describe_command() { + let args = vec![ + "granc", + "describe", + "helloworld.Greeter", + "--uri", + "http://localhost:50051", + ]; + let cli = Cli::try_parse_from(&args).expect("Parsing failed"); + + match cli.command { + Commands::Describe { symbol, source } => { + assert_eq!(symbol, "helloworld.Greeter"); + assert!(source.uri.is_some()); + } + _ => panic!("Expected Describe command"), + } + } + + // --- Failure Cases --- + + #[test] + fn test_fail_invalid_json_body() { + let args = vec!["granc", "call", "s/m", "-u", "x", "--body", "{invalid_json"]; + let err = Cli::try_parse_from(&args).unwrap_err(); + // Should verify that the error comes from the body parser + assert!(err.to_string().contains("Invalid JSON")); + } + + #[test] + fn test_fail_invalid_endpoint_format() { + let args = vec![ + "granc", + "call", + "OnlyServiceNoMethod", // Missing '/' + "-u", + "x", + "-b", + "{}", + ]; + let err = Cli::try_parse_from(&args).unwrap_err(); + assert!(err.to_string().contains("Invalid endpoint format")); + } + + #[test] + fn test_fail_list_requires_source() { + let args = vec!["granc", "list"]; + let err = Cli::try_parse_from(&args).unwrap_err(); + // Clap error for missing required arguments in group + assert!(err.kind() == clap::error::ErrorKind::MissingRequiredArgument); + } + + #[test] + fn test_fail_list_mutual_exclusion() { + let args = vec![ + "granc", + "list", + "--uri", + "http://host", + "--file-descriptor-set", + "file.bin", + ]; + let err = Cli::try_parse_from(&args).unwrap_err(); + // Clap error for argument conflict + assert!(err.kind() == clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn test_fail_describe_mutual_exclusion() { + let args = vec![ + "granc", + "describe", + "Symbol", + "-u", + "http://host", + "-f", + "file.bin", + ]; + let err = Cli::try_parse_from(&args).unwrap_err(); + assert!(err.kind() == clap::error::ErrorKind::ArgumentConflict); + } +} diff --git a/granc/src/formatter.rs b/granc/src/formatter.rs index 3881cfc..66f8478 100644 --- a/granc/src/formatter.rs +++ b/granc/src/formatter.rs @@ -1,6 +1,6 @@ use colored::*; use granc_core::{ - client::{with_file_descriptor, with_server_reflection}, + client::{Descriptor, DynamicResponse, online, online_without_reflection}, prost_reflect::{ self, EnumDescriptor, Kind, MessageDescriptor, MethodDescriptor, ServiceDescriptor, }, @@ -42,16 +42,36 @@ impl From for FormattedString { } } +impl From for FormattedString { + fn from(value: DynamicResponse) -> Self { + match value { + DynamicResponse::Unary(Ok(value)) => FormattedString::from(value), + DynamicResponse::Unary(Err(status)) => FormattedString::from(status), + DynamicResponse::Streaming(Ok(values)) => { + let mut s = String::new(); + for elem in values { + match elem { + Ok(val) => s.push_str(&FormattedString::from(val).0), + Err(status) => s.push_str(&FormattedString::from(status).0), + } + } + FormattedString(s) + } + DynamicResponse::Streaming(Err(status)) => FormattedString::from(status), + } + } +} + // Error from Reflection-based calls -impl From for FormattedString { - fn from(err: with_server_reflection::DynamicCallError) -> Self { +impl From for FormattedString { + fn from(err: online::DynamicCallError) -> Self { FormattedString(format!("{}\n\n'{}'", "Call Failed:".red().bold(), err)) } } // Error from FileDescriptor-based calls -impl From for FormattedString { - fn from(err: with_file_descriptor::DynamicCallError) -> Self { +impl From for FormattedString { + fn from(err: online_without_reflection::DynamicCallError) -> Self { FormattedString(format!("{}\n\n'{}'", "Call Failed:".red().bold(), err)) } } @@ -82,14 +102,14 @@ impl From> for FormattedString { } } -impl From for FormattedString { - fn from(err: with_server_reflection::ClientConnectError) -> Self { +impl From for FormattedString { + fn from(err: online::ClientConnectError) -> Self { FormattedString(format!("{}\n\n'{}'", "Connection Error:".red().bold(), err)) } } -impl From for FormattedString { - fn from(err: with_server_reflection::GetDescriptorError) -> Self { +impl From for FormattedString { + fn from(err: online::GetDescriptorError) -> Self { FormattedString(format!( "{}\n\n'{}'", "Symbol Lookup Failed:".red().bold(), @@ -113,6 +133,16 @@ impl From for FormattedString { } } +impl From for FormattedString { + fn from(value: Descriptor) -> Self { + match value { + Descriptor::MessageDescriptor(d) => FormattedString::from(d), + Descriptor::ServiceDescriptor(d) => FormattedString::from(d), + Descriptor::EnumDescriptor(d) => FormattedString::from(d), + } + } +} + impl From for FormattedString { fn from(service: ServiceDescriptor) -> Self { let mut out = String::new(); diff --git a/granc/src/main.rs b/granc/src/main.rs index c96d5a5..bf2c12b 100644 --- a/granc/src/main.rs +++ b/granc/src/main.rs @@ -3,149 +3,133 @@ //! The main executable for the Granc tool. This file drives the application lifecycle: //! //! 1. **Initialization**: Parses command-line arguments using [`cli::Cli`]. -//! 2. **Connection**: Establishes a TCP connection to the target server via `granc_core`. -//! 3. **Execution**: Delegates the request processing to the `GrancClient` (handling state transitions). -//! 4. **Presentation**: Formats and prints the resulting data or errors to standard output/error. - +//! 2. **Dispatch**: Routes the command to the appropriate handler based on input arguments +//! (connecting to server vs loading local file). +//! 3. **Execution**: Delegates request processing to `GrancClient`. +//! 4. **Presentation**: Formats and prints data. mod cli; mod formatter; use clap::Parser; -use cli::{Cli, Commands}; -use formatter::{FormattedString, GenericError, ServiceList}; -use granc_core::client::{ - Descriptor, DynamicRequest, DynamicResponse, GrancClient, WithFileDescriptor, - WithServerReflection, -}; -use granc_core::tonic::transport::Channel; +use cli::{Cli, Commands, Source}; +use formatter::{FormattedString, GenericError}; +use granc_core::client::{Descriptor, DynamicRequest, DynamicResponse, GrancClient}; use std::process; #[tokio::main] async fn main() { let args = Cli::parse(); - let client = unwrap_or_exit(GrancClient::connect(&args.url).await); + match args.command { + Commands::Call { + endpoint, + uri, + body, + headers, + file_descriptor_set, + } => { + let response = call(endpoint, uri, body, headers, file_descriptor_set).await; + println!("{}", FormattedString::from(response)) + } - if let Some(path) = args.file_descriptor_set { - let bytes = unwrap_or_exit(std::fs::read(&path)); - let client = unwrap_or_exit(client.with_file_descriptor(bytes)); - handle_file_descriptor_mode(client, args.command).await; + Commands::List { source } => { + let services = list(source.value()).await; + println!( + "{}", + FormattedString::from(formatter::ServiceList(services)) + ) + } + + Commands::Describe { symbol, source } => { + let descriptor = describe(symbol, source.value()).await; + println!("{}", FormattedString::from(descriptor)) + } + } +} + +async fn call( + endpoint: (String, String), + uri: String, + body: serde_json::Value, + headers: Vec<(String, String)>, + file_descriptor_set: Option, +) -> DynamicResponse { + let (service, method) = endpoint; + + let request = DynamicRequest { + service, + method, + body, + headers, + }; + + let mut client = GrancClient::connect(&uri).await.unwrap_or_exit(); + + if let Some(path) = file_descriptor_set { + let bytes = std::fs::read(path).unwrap_or_exit(); + let mut client = client.with_file_descriptor(bytes).unwrap_or_exit(); + client.dynamic(request).await.unwrap_or_exit() } else { - handle_reflection_mode(client, args.command).await; + client.dynamic(request).await.unwrap_or_exit() } } -async fn handle_reflection_mode( - mut client: GrancClient>, - command: Commands, -) { - match command { - Commands::Call { - endpoint, - body, - headers, - } => { - let (service, method) = endpoint; - let request = DynamicRequest { - body, - headers, - service, - method, - }; +async fn list(source: Source) -> Vec { + match source { + Source::Uri(uri) => { + let mut client = GrancClient::connect(&uri).await.unwrap_or_exit(); + client + .list_services() + .await + .map_err(|e| GenericError("Failed to list services:", e)) + .unwrap_or_exit() + } - let response = unwrap_or_exit(client.dynamic(request).await); - print_response(response); - } - Commands::List => { - let services = unwrap_or_exit( - client - .list_services() - .await - .map_err(|err| GenericError("Failed to list services:", err)), - ); - println!("{}", FormattedString::from(ServiceList(services))); - } - Commands::Describe { symbol } => { - let descriptor = unwrap_or_exit(client.get_descriptor_by_symbol(&symbol).await); - print_descriptor(descriptor); + Source::File(path) => { + let fd_bytes = std::fs::read(path).unwrap_or_exit(); + let client = GrancClient::offline(fd_bytes).unwrap_or_exit(); + client.list_services() } } } -// --- Handler for File Descriptor Mode --- - -async fn handle_file_descriptor_mode( - mut client: GrancClient>, - command: Commands, -) { - match command { - Commands::Call { - endpoint, - body, - headers, - } => { - let (service, method) = endpoint; - let request = DynamicRequest { - body, - headers, - service, - method, - }; - - let response = unwrap_or_exit(client.dynamic(request).await); - print_response(response); +async fn describe(symbol: String, source: Source) -> Descriptor { + match source { + Source::Uri(uri) => { + let mut client = GrancClient::connect(&uri).await.unwrap_or_exit(); + client + .get_descriptor_by_symbol(&symbol) + .await + .unwrap_or_exit() } - Commands::List => { - let services = client.list_services(); - println!("{}", FormattedString::from(ServiceList(services))); - } - Commands::Describe { symbol } => { - let descriptor = unwrap_or_exit( - client - .get_descriptor_by_symbol(&symbol) - .ok_or(GenericError("Symbol not found", symbol)), - ); - print_descriptor(descriptor); + + Source::File(path) => { + let fd_bytes = std::fs::read(path).unwrap_or_exit(); + let client = GrancClient::offline(fd_bytes).unwrap_or_exit(); + client + .get_descriptor_by_symbol(&symbol) + .ok_or(GenericError("Symbol not found", symbol)) + .unwrap_or_exit() } } } -/// Helper function to return the Ok value or print the error and exit. -fn unwrap_or_exit(result: Result) -> T +// Utility trait to standardize the way we handle errors in the program +trait UnwrapOrExit { + fn unwrap_or_exit(self) -> T; +} + +impl UnwrapOrExit for Result where E: Into, { - match result { - Ok(v) => v, - Err(e) => { - eprintln!("{}", Into::::into(e)); - process::exit(1); - } - } -} - -fn print_descriptor(descriptor: Descriptor) { - match descriptor { - Descriptor::MessageDescriptor(d) => println!("{}", FormattedString::from(d)), - Descriptor::ServiceDescriptor(d) => println!("{}", FormattedString::from(d)), - Descriptor::EnumDescriptor(d) => println!("{}", FormattedString::from(d)), - } -} - -fn print_response(response: DynamicResponse) { - match response { - DynamicResponse::Unary(Ok(value)) => println!("{}", FormattedString::from(value)), - DynamicResponse::Unary(Err(status)) => println!("{}", FormattedString::from(status)), - DynamicResponse::Streaming(Ok(values)) => { - for elem in values { - match elem { - Ok(val) => println!("{}", FormattedString::from(val)), - Err(status) => println!("{}", FormattedString::from(status)), - } + fn unwrap_or_exit(self) -> T { + match self { + Ok(v) => v, + Err(e) => { + eprintln!("{}", Into::::into(e)); + process::exit(1); } } - DynamicResponse::Streaming(Err(status)) => { - println!("{}", FormattedString::from(status)) - } } }