diff --git a/Cargo.lock b/Cargo.lock index c2ceffe..380f271 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -336,34 +336,13 @@ dependencies = [ [[package]] name = "granc" -version = "0.5.0" +version = "0.5.1" dependencies = [ "clap", "colored", - "granc_core 0.4.0", + "granc_core", "serde_json", "tokio", - "tonic", -] - -[[package]] -name = "granc_core" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "470843569f07903a7e34f3b37c8d155e3a13e8450da1aea4dd6126370ce8259d" -dependencies = [ - "futures-util", - "http", - "http-body", - "prost", - "prost-reflect", - "prost-types", - "serde_json", - "thiserror", - "tokio", - "tokio-stream", - "tonic", - "tonic-prost", ] [[package]] @@ -382,7 +361,6 @@ dependencies = [ "tokio", "tokio-stream", "tonic", - "tonic-prost", "tonic-reflection", ] @@ -703,9 +681,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -798,9 +776,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -919,9 +897,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", @@ -1322,6 +1300,6 @@ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "zmij" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" diff --git a/Cargo.toml b/Cargo.toml index c833535..17944f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,6 @@ homepage = "https://github.com/JasterV/granc" license = "MIT OR Apache-2.0" repository = "https://github.com/JasterV/granc" rust-version = "1.89" -version = "0.3.1" [workspace.dependencies] serde_json = "1.0.149" @@ -21,6 +20,4 @@ prost = "0.14" prost-reflect = "0.16.3" prost-types = "0.14" tonic = "0.14" -tonic-prost = "0.14" -tonic-prost-build = "0.14" tonic-reflection = "0.14" diff --git a/README.md b/README.md index e870eee..2170b7e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ > ⚠️ **Status: Experimental** > -> This project is currently in a **highly experimental phase**. It is a working prototype intended for testing and development purposes. APIs, command-line arguments, and internal logic are subject to breaking changes. Please use with caution. +> This project is a working prototype intended for testing and development purposes. APIs, command-line arguments, and internal logic are subject to breaking changes. Please use with caution. **Granc** (gRPC + Cranc, Crab in Catalan) is a lightweight, dynamic gRPC CLI tool written in Rust. @@ -13,14 +13,16 @@ 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`. -* **Smart Dispatch**: Automatically detects if a call is Unary, Server Streaming, Client Streaming, or Bidirectional based on the descriptor. -* **Server Reflection**: Can fetch schemas directly from the server, removing the need to pass a local file descriptor set file (`.bin` or `.pb`). * **Metadata Support**: Easily attach custom headers (authorization, tracing) to your requests. * **Fast Fail Validation**: Validates your JSON *before* hitting the network. -* **Introspection Tools**: Commands to list services and describe services/messages. +* **Smart Dispatch**: Automatically detects if a call is Unary, Server Streaming, Client Streaming, or Bidirectional based on the descriptor. +* **Server Reflection**: Can fetch schemas directly from the server, removing the need to pass a local file descriptor set file (`.bin` or `.pb`). +* **Introspection Tools**: Commands to list services and describe services, messages, and enums. + * **Local Introspection**: In addition to making network requests, Granc can also be used as a local introspection tool for file descriptor binary files. You can load a local `.bin` file to inspect services, messages, and enums without needing to fetch the schema from a server. * **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. @@ -57,7 +59,7 @@ protoc \ **Syntax:** ```bash -granc [ARGS] +granc [OPTIONS] [ARGS] ``` ### Global Arguments @@ -65,6 +67,7 @@ granc [ARGS] | 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 @@ -73,7 +76,7 @@ granc [ARGS] Performs a gRPC call using a JSON body. ```bash -granc http://localhost:50051 call --body [OPTIONS] +granc http://localhost:50051 [OPTIONS] call --body [ARGS] ``` | Argument/Flag | Description | Required | @@ -81,49 +84,86 @@ granc http://localhost:50051 call --body [OPTIONS] | `` | 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 - -* **Unary / Server Streaming**: Provide a single JSON object `{ ... }`. -* **Client / Bidirectional Streaming**: Provide a JSON array of objects `[ { ... }, { ... } ]`. - -##### Automatic Server Reflection - -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. +**Example using Server Reflection:** ```bash -granc http://localhost:50051 call --body '{"name": "Ferris"}' helloworld.Greeter/SayHello +granc http://localhost:50051 call helloworld.Greeter/SayHello --body '{"name": "Ferris"}' ``` -This requires the server to have the [`grpc.reflection.v1`](https://github.com/grpc/grpc-proto/blob/master/grpc/reflection/v1/reflection.proto) service enabled. +```json +{ + "message": "Hello Ferris" +} +``` -#### 2. `list` (Service Discovery) (Server reflection required) +**Example using a Local Descriptor File:** -Lists all services exposed by the server. +```bash +granc http://localhost:50051 --file-descriptor-set ./descriptors.bin call helloworld.Greeter/SayHello --body '{"name": "Ferris"}' +``` + +#### 2. `list` (Service Discovery) + +Lists all services exposed by the server (via reflection) or contained in the provided descriptor file. ```bash 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 +``` +Available Services: + - grpc.reflection.v1.ServerReflection + - helloworld.Greeter ``` -**Describe Message:** - -Shows the fields of a specific message type. +**Listing services from a file:** ```bash -granc http://localhost:50051 describe my.package.HelloRequest +granc http://localhost:50051 --file-descriptor-set ./descriptors.bin list +``` + +#### 3. `describe` (Introspection) + +Inspects a specific symbol (Service, Message, or Enum) and prints its Protobuf definition in a colored, human-readable format. + +```bash +granc http://localhost:50051 describe helloworld.Greeter +``` + +```proto +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 +``` + +```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 +``` + +```proto +enum Status { + UNKNOWN = 0; + ACTIVE = 1; + INACTIVE = 2; +} ``` ## 🔮 Roadmap @@ -138,7 +178,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**](./granc-core/README.md) for examples on how to use the `GrancClient` programmatically. +* **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. * **Crate**: [`granc-core`](https://crates.io/crates/granc_core) ## ⚠️ Common Errors @@ -164,6 +204,7 @@ Contributions are welcome! Please run the Makefile checks before submitting a PR ```bash cargo make ci # Checks formatting, lints, and runs tests + ``` ## 📄 License diff --git a/granc-core/Cargo.toml b/granc-core/Cargo.toml index 874f577..ff50164 100644 --- a/granc-core/Cargo.toml +++ b/granc-core/Cargo.toml @@ -29,7 +29,6 @@ thiserror = "2.0.18" tokio = { workspace = true, features = ["sync"] } tokio-stream = "0.1.18" tonic = { workspace = true } -tonic-prost = { workspace = true } tonic-reflection = { workspace = true } [dev-dependencies] diff --git a/granc-core/README.md b/granc-core/README.md index 98bf645..98cf98f 100644 --- a/granc-core/README.md +++ b/granc-core/README.md @@ -12,13 +12,11 @@ Instead of strictly typed Rust structs, this library bridges standard `serde_jso 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. Making a Dynamic Call +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`. -The `dynamic` method handles the full request lifecycle: +### 1. Using Server Reflection (Default) -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. +By default, when you connect, the client is ready to use the server's reflection service to resolve methods and types dynamically. ```rust use granc_core::client::{GrancClient, DynamicRequest, DynamicResponse}; @@ -26,35 +24,23 @@ use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box> { - // Connect to the server + // Connect (starts in Reflection mode) let mut client = GrancClient::connect("http://localhost:50051").await?; - // Prepare the request - // If you don't provide a file_descriptor_set, the client will attempt - // to fetch the schema from the server's reflection service automatically. let request = DynamicRequest { service: "helloworld.Greeter".to_string(), method: "SayHello".to_string(), body: json!({ "name": "World" }), headers: vec![], - file_descriptor_set: None, // Uses Server Reflection }; + // Execute (Schema is fetched automatically via reflection) let response = client.dynamic(request).await?; match response { - DynamicResponse::Unary(Ok(value)) => { - println!("Response: {}", value); - } - DynamicResponse::Unary(Err(status)) => { - eprintln!("gRPC Error: {:?}", status); - } - DynamicResponse::Streaming(Ok(stream)) => { - for msg in stream { - println!("Stream Msg: {:?}", msg); - } - } - _ => eprintln!("Unexpected response type"), + DynamicResponse::Unary(Ok(value)) => println!("Response: {}", value), + DynamicResponse::Unary(Err(status)) => eprintln!("gRPC Error: {:?}", status), + _ => {} } Ok(()) @@ -62,22 +48,66 @@ async fn main() -> Result<(), Box> { ``` -### 2. Schema Introspection +### 2. Using a Local Descriptor File -`GrancClient` exposes several methods to inspect the server's available services and types using reflection. +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. ```rust -// List all services exposed by the server -let services = client.list_services().await?; -println!("Available Services: {:?}", services); +use granc_core::client::GrancClient; -// Get the descriptor for a specific type +#[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 + 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); + + Ok(()) +} + +``` + +### 3. Schema Introspection + +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) + +```rust +// List available services (requires network call) +let services = client.list_services().await?; + +// Get a specific descriptor (requires network call) +// Returns Result 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()) + Descriptor::ServiceDescriptor(svc) => println!("Service: {}", svc.name()), + Descriptor::MessageDescriptor(msg) => println!("Message: {}", msg.name()), + Descriptor::EnumDescriptor(enm) => println!("Enum: {}", enm.name()), +} +``` + +#### 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"); } ``` @@ -121,21 +151,6 @@ 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. -```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. - ## ⚖️ License Licensed under either of Apache License, Version 2.0 or MIT license at your option. diff --git a/granc-core/src/client.rs b/granc-core/src/client.rs index 8de1ea6..cf0a0d7 100644 --- a/granc-core/src/client.rs +++ b/granc-core/src/client.rs @@ -1,103 +1,72 @@ //! # Granc Client //! -//! This module implements the high-level logic for executing dynamic gRPC requests -//! and offers support for reflection operations if the server supports it. +//! This module implements the high-level logic for executing dynamic gRPC requests. //! -//! 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. +//! The [`GrancClient`] uses a **Typestate Pattern** to ensure safety and correctness regarding +//! how the Protobuf schema is resolved. It has two possible states: //! -//! ## Example Usage +//! 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. +//! +//! ## Example: State Transition //! //! ```rust,no_run -//! use granc_core::client::{GrancClient, DynamicRequest}; -//! use serde_json::json; +//! use granc_core::client::GrancClient; //! //! # async fn run() -> Result<(), Box> { -//! // 1. Connect to the server -//! let mut client = GrancClient::connect("http://localhost:50051").await?; +//! // Connect (starts in Reflection state) +//! let mut client_reflection = 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, -//! }; +//! // The API here is async +//! let services = client_reflection.list_services().await?; //! -//! // 3. Execute the call -//! let response = client.dynamic(request).await?; -//! println!("Response: {:?}", response); +//! // 2Transition to File Descriptor state +//! let bytes = std::fs::read("descriptor.bin")?; +//! let mut client_fd = client_reflection.with_file_descriptor(bytes)?; +//! +//! // Now operations use the local file and are sync +//! let services = client_fd.list_services(); //! # Ok(()) //! # } //! ``` -use crate::{ - BoxError, - grpc::client::{GrpcClient, GrpcRequestError}, - reflection::client::{ReflectionClient, ReflectionResolveError}, -}; -use futures_util::Stream; -use http_body::Body as HttpBody; -use prost_reflect::{ - DescriptorError, DescriptorPool, EnumDescriptor, MessageDescriptor, ServiceDescriptor, -}; -use tokio_stream::StreamExt; -use tonic::{ - Code, - transport::{Channel, Endpoint}, -}; +pub mod with_file_descriptor; +pub mod with_server_reflection; -#[derive(Debug, thiserror::Error)] -pub enum ClientConnectError { - #[error("Invalid URL '{0}': {1}")] - InvalidUrl(String, #[source] tonic::transport::Error), - #[error("Failed to connect to '{0}': {1}")] - ConnectionFailed(String, #[source] tonic::transport::Error), +use crate::{grpc::client::GrpcClient, reflection::client::ReflectionClient}; +use prost_reflect::{DescriptorPool, EnumDescriptor, MessageDescriptor, ServiceDescriptor}; +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. +#[derive(Clone, Debug)] +pub struct GrancClient { + state: T, } -#[derive(Debug, thiserror::Error)] -pub enum ListServicesError { - #[error("Reflection resolution failed: '{0}'")] - ReflectionResolve(#[from] ReflectionResolveError), +/// The state for a client that uses a local `DescriptorPool` for schema resolution. +#[derive(Debug, Clone)] +pub struct WithFileDescriptor { + grpc_client: GrpcClient, + pool: DescriptorPool, } -#[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("Service '{0}' not found")] - ServiceNotFound(String), - - #[error("Method '{0}' not found")] - MethodNotFound(String), - - #[error("Reflection resolution failed: '{0}'")] - ReflectionResolve(#[from] ReflectionResolveError), - - #[error("Failed to decode file descriptor set: '{0}'")] - DescriptorError(#[from] DescriptorError), - - #[error("gRPC client request error: '{0}'")] - GrpcRequestError(#[from] GrpcRequestError), +/// The state for a client that uses Server Reflection for schema resolution. +#[derive(Debug, Clone)] +pub struct WithServerReflection { + reflection_client: ReflectionClient, + grpc_client: GrpcClient, } /// A request object encapsulating all necessary information to perform a dynamic gRPC call. +#[derive(Debug, Clone)] 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>, /// The JSON body of the request. /// - For Unary/ServerStreaming: An Object `{}`. /// - For ClientStreaming/Bidirectional: An Array of Objects `[{}]`. @@ -111,6 +80,7 @@ pub struct DynamicRequest { } /// 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), @@ -118,7 +88,10 @@ pub enum DynamicResponse { Streaming(Result>, tonic::Status>), } -/// A file descriptor of either a message, service or enum +/// 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), @@ -127,6 +100,7 @@ pub enum Descriptor { } 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), @@ -134,6 +108,7 @@ impl Descriptor { } } + /// 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), @@ -141,6 +116,7 @@ impl Descriptor { } } + /// 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), @@ -148,212 +124,3 @@ impl Descriptor { } } } - -/// 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 { - reflection_client: ReflectionClient, - grpc_client: GrpcClient, -} - -impl GrancClient { - /// 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 { - let endpoint = Endpoint::new(addr.to_string()) - .map_err(|e| ClientConnectError::InvalidUrl(addr.to_string(), e))?; - - let channel = endpoint - .connect() - .await - .map_err(|e| ClientConnectError::ConnectionFailed(addr.to_string(), e))?; - - Ok(Self::new(channel)) - } -} - -impl 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`). - pub fn new(service: S) -> Self { - let reflection_client = ReflectionClient::new(service.clone()); - let grpc_client = GrpcClient::new(service); - - Self { - reflection_client, - grpc_client, - } - } - - /// 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, 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 { - 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 { - let pool = match request.file_descriptor_set { - Some(bytes) => DescriptorPool::decode(bytes.as_slice())?, - // If no proto-set file is passed, we'll try to reach the server reflection service - None => { - let fd_set = self - .reflection_client - .file_descriptor_set_by_symbol(&request.service) - .await?; - DescriptorPool::from_file_descriptor_set(fd_set)? - } - }; - - let method = 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 - .grpc_client - .unary(method, request.body, request.headers) - .await?; - Ok(DynamicResponse::Unary(result)) - } - - (false, true) => { - match self - .grpc_client - .server_streaming(method, request.body, request.headers) - .await? - { - Ok(stream) => Ok(DynamicResponse::Streaming(Ok(stream.collect().await))), - Err(status) => Ok(DynamicResponse::Streaming(Err(status))), - } - } - (true, false) => { - let input_stream = - json_array_to_stream(request.body).map_err(DynamicCallError::InvalidInput)?; - let result = self - .grpc_client - .client_streaming(method, input_stream, request.headers) - .await?; - Ok(DynamicResponse::Unary(result)) - } - - (true, true) => { - let input_stream = - json_array_to_stream(request.body).map_err(DynamicCallError::InvalidInput)?; - match self - .grpc_client - .bidirectional_streaming(method, input_stream, request.headers) - .await? - { - Ok(stream) => Ok(DynamicResponse::Streaming(Ok(stream.collect().await))), - Err(status) => Ok(DynamicResponse::Streaming(Err(status))), - } - } - } - } -} - -/// 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/src/client/with_file_descriptor.rs b/granc-core/src/client/with_file_descriptor.rs new file mode 100644 index 0000000..fab2858 --- /dev/null +++ b/granc-core/src/client/with_file_descriptor.rs @@ -0,0 +1,178 @@ +//! # 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/src/client/with_server_reflection.rs b/granc-core/src/client/with_server_reflection.rs new file mode 100644 index 0000000..1c1ffc5 --- /dev/null +++ b/granc-core/src/client/with_server_reflection.rs @@ -0,0 +1,206 @@ +//! # Client State: Server Reflection +//! +//! This module defines the `GrancClient` behavior when it is using the server's reflection service +//! to resolve schemas. +use super::{ + Descriptor, DynamicRequest, DynamicResponse, GrancClient, WithFileDescriptor, + WithServerReflection, +}; +use crate::{ + BoxError, + grpc::client::GrpcClient, + reflection::client::{ReflectionClient, ReflectionResolveError}, +}; +use http_body::Body as HttpBody; +use prost_reflect::DescriptorError; +use prost_reflect::DescriptorPool; +use std::fmt::Debug; +use tonic::{ + Code, + transport::{Channel, Endpoint}, +}; + +#[derive(Debug, thiserror::Error)] +pub enum ClientConnectError { + #[error("Invalid URL '{0}': {1}")] + InvalidUrl(String, #[source] tonic::transport::Error), + #[error("Failed to connect to '{0}': {1}")] + ConnectionFailed(String, #[source] tonic::transport::Error), +} + +#[derive(Debug, thiserror::Error)] +pub enum 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), +} + +#[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), +} + +impl GrancClient> { + /// Connects to a gRPC server at the specified address. + /// + /// This initializes the client in the **Reflection** state. It establishes a TCP connection + /// but does not yet perform any reflection calls. + /// + /// # Arguments + /// + /// * `addr` - The URI of the server (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. + pub async fn connect(addr: &str) -> Result { + let endpoint = Endpoint::new(addr.to_string()) + .map_err(|e| ClientConnectError::InvalidUrl(addr.to_string(), e))?; + + let channel = endpoint + .connect() + .await + .map_err(|e| ClientConnectError::ConnectionFailed(addr.to_string(), e))?; + + Ok(Self::from_service(channel)) + } +} + +impl 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 { + let reflection_client = ReflectionClient::new(service.clone()); + let grpc_client = GrpcClient::new(service); + + Self { + state: WithServerReflection { + reflection_client, + grpc_client, + }, + } + } + + /// Transitions the client to the **File Descriptor** state. + /// + /// This method consumes the current client and returns a new client that uses the provided + /// binary `FileDescriptorSet` for all schema lookups, disabling server reflection. + /// + /// # Arguments + /// + /// * `file_descriptor` - A vector of bytes containing the encoded `FileDescriptorSet` (protobuf binary format). + /// + /// # Returns + /// + /// * `Ok(GrancClient)` - The new client state. + /// * `Err(DescriptorError)` - If the provided bytes could not be decoded into a valid `DescriptorPool`. + pub fn with_file_descriptor( + self, + file_descriptor: Vec, + ) -> Result>, DescriptorError> { + let pool = DescriptorPool::decode(file_descriptor.as_slice())?; + Ok(GrancClient::new(self.state.grpc_client, pool)) + } + + /// Lists all services exposed by the server by querying the reflection endpoint. + /// + /// # 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. + 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. + /// + /// This will recursively fetch the file defining the symbol and all its dependencies + /// from the server. + /// + /// # Arguments + /// + /// * `symbol` - The fully qualified name of the symbol (Service, Message, or Enum). + /// + /// # Returns + /// + /// * `Ok(Descriptor)` - The resolved descriptor wrapper. + /// * `Err(GetDescriptorError)` - If the symbol is not found or reflection fails. + pub async fn get_descriptor_by_symbol( + &mut self, + symbol: &str, + ) -> Result { + let fd_set = self + .state + .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)?; + + let mut client = + GrancClient::>::new(self.state.grpc_client.clone(), 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. + /// + /// # Arguments + /// + /// * `request` - The [`DynamicRequest`] containing the method to call and the JSON body. + /// + /// # Returns + /// + /// * `Ok(DynamicResponse)` - The result of the gRPC call (Unary or Streaming). + /// * `Err(DynamicCallError)` - If schema resolution, validation, or the network call fails. + pub async fn dynamic( + &mut self, + request: DynamicRequest, + ) -> Result { + let fd_set = self + .state + .reflection_client + .file_descriptor_set_by_symbol(&request.service) + .await?; + + let pool = DescriptorPool::from_file_descriptor_set(fd_set)?; + + let mut client = + GrancClient::>::new(self.state.grpc_client.clone(), pool); + + Ok(client.dynamic(request).await?) + } +} diff --git a/granc-core/src/grpc/client.rs b/granc-core/src/grpc/client.rs index 29d6648..5ed9164 100644 --- a/granc-core/src/grpc/client.rs +++ b/granc-core/src/grpc/client.rs @@ -48,6 +48,7 @@ pub enum GrpcRequestError { } /// A generic client for the gRPC Server Reflection Protocol. +#[derive(Debug, Clone)] pub struct GrpcClient { client: tonic::client::Grpc, } diff --git a/granc-core/src/reflection/client.rs b/granc-core/src/reflection/client.rs index b52cb00..364d69f 100644 --- a/granc-core/src/reflection/client.rs +++ b/granc-core/src/reflection/client.rs @@ -65,6 +65,7 @@ pub enum ReflectionResolveError { const EMPTY_HOST: &str = ""; /// A client for interacting with the gRPC Server Reflection Service. +#[derive(Debug, Clone)] pub struct ReflectionClient { client: ServerReflectionClient, } diff --git a/granc-core/tests/dummy_echo_service_impl.rs b/granc-core/tests/dummy_echo_service_impl.rs deleted file mode 100644 index 6d8420c..0000000 --- a/granc-core/tests/dummy_echo_service_impl.rs +++ /dev/null @@ -1,42 +0,0 @@ -use echo_service::EchoService; -use echo_service::pb::{EchoRequest, EchoResponse}; -use tokio_stream::wrappers::ReceiverStream; -use tonic::{Request, Response, Status, Streaming}; - -// A minimal service that satisfies the EchoService trait. -// We don't need real logic here, just enough to compile. -pub struct DummyEchoService; - -#[tonic::async_trait] -impl EchoService for DummyEchoService { - type ServerStreamingEchoStream = ReceiverStream>; - type BidirectionalEchoStream = ReceiverStream>; - - async fn unary_echo( - &self, - _req: Request, - ) -> Result, Status> { - unimplemented!("This will never be used") - } - - async fn server_streaming_echo( - &self, - _req: Request, - ) -> Result, Status> { - unimplemented!("This will never be used") - } - - async fn client_streaming_echo( - &self, - _req: Request>, - ) -> Result, Status> { - unimplemented!("This will never be used") - } - - async fn bidirectional_echo( - &self, - _req: Request>, - ) -> Result, Status> { - unimplemented!("This will never be used") - } -} diff --git a/granc-core/tests/echo_service_impl.rs b/granc-core/tests/echo_service_impl.rs index 99d3a55..b064bf7 100644 --- a/granc-core/tests/echo_service_impl.rs +++ b/granc-core/tests/echo_service_impl.rs @@ -1,11 +1,13 @@ use echo_service::EchoService; use echo_service::pb::{EchoRequest, EchoResponse}; use futures_util::Stream; +use futures_util::StreamExt; use std::pin::Pin; use tokio::sync::mpsc; -use tokio_stream::{StreamExt, wrappers::ReceiverStream}; +use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status, Streaming}; +#[derive(Debug)] pub struct EchoServiceImpl; #[tonic::async_trait] diff --git a/granc-core/tests/granc_client_file_descriptor_test.rs b/granc-core/tests/granc_client_file_descriptor_test.rs new file mode 100644 index 0000000..cc11584 --- /dev/null +++ b/granc-core/tests/granc_client_file_descriptor_test.rs @@ -0,0 +1,186 @@ +use echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET}; +use echo_service_impl::EchoServiceImpl; +use granc_core::client::{ + Descriptor, DynamicRequest, DynamicResponse, GrancClient, WithFileDescriptor, + with_file_descriptor, +}; +use tonic::Code; + +mod echo_service_impl; + +fn setup_client() -> GrancClient>> { + let service = EchoServiceServer::new(EchoServiceImpl); + let client_reflection = GrancClient::from_service(service); + + client_reflection + .with_file_descriptor(FILE_DESCRIPTOR_SET.to_vec()) + .expect("Failed to load file descriptor set") +} + +#[tokio::test] +async fn test_list_services() { + let mut client = setup_client(); + + let services = client.list_services(); + + assert_eq!(services.as_slice(), ["echo.EchoService"]); +} + +#[tokio::test] +async fn test_describe_descriptors() { + let mut client = setup_client(); + + // Describe Service + let desc = client + .get_descriptor_by_symbol("echo.EchoService") + .expect("Service not found"); + + assert!(matches!( + desc, + Descriptor::ServiceDescriptor(s) if s.name() == "EchoService" + )); + + // Describe Message + let desc = client + .get_descriptor_by_symbol("echo.EchoRequest") + .expect("Message not found"); + + assert!(matches!( + desc, + Descriptor::MessageDescriptor(m) if m.name() == "EchoRequest" + )); + + // Error Case: Returns None + let desc = client.get_descriptor_by_symbol("echo.Ghost"); + + assert!(desc.is_none()); +} + +#[tokio::test] +async fn test_dynamic_calls() { + let mut client = setup_client(); + + // Unary Call + let req = DynamicRequest { + service: "echo.EchoService".to_string(), + method: "UnaryEcho".to_string(), + body: serde_json::json!({ "message": "hello" }), + headers: vec![], + }; + + let res = client.dynamic(req).await.unwrap(); + + assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "hello")); + + // Server Streaming + let req = DynamicRequest { + service: "echo.EchoService".to_string(), + method: "ServerStreamingEcho".to_string(), + body: serde_json::json!({ "message": "stream" }), + headers: vec![], + }; + + 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" + )); +} + +#[tokio::test] +async fn test_error_cases() { + let mut client = setup_client(); + + // Service Not Found + let req = DynamicRequest { + service: "echo.GhostService".to_string(), + method: "UnaryEcho".to_string(), + body: serde_json::json!({}), + headers: vec![], + }; + + let result = client.dynamic(req).await; + + assert!(matches!( + result, + Err(with_file_descriptor::DynamicCallError::ServiceNotFound(name)) if name == "echo.GhostService" + )); + + // Method Not Found + let req = DynamicRequest { + service: "echo.EchoService".to_string(), + method: "GhostMethod".to_string(), + body: serde_json::json!({}), + headers: vec![], + }; + + let result = client.dynamic(req).await; + + assert!(matches!( + result, + Err(with_file_descriptor::DynamicCallError::MethodNotFound(name)) if name == "GhostMethod" + )); + + // Invalid JSON Structure (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" }), + headers: vec![], + }; + + let result = client.dynamic(req).await; + + assert!(matches!( + result, + Err(with_file_descriptor::DynamicCallError::InvalidInput(_)) + )); + + // Schema Mismatch (Unary) + // Field mismatch causes encoding error -> Status::InvalidArgument + let req = DynamicRequest { + service: "echo.EchoService".to_string(), + method: "UnaryEcho".to_string(), + body: serde_json::json!({ "unknown_field": 123 }), + headers: vec![], + }; + + let result = client.dynamic(req).await; + + assert!(matches!( + result, + Ok(DynamicResponse::Unary(Err(status))) + if status.code() == Code::Internal + && status.message().contains("JSON structure does not match") + )); +} diff --git a/granc-core/tests/granc_client_reflection_test.rs b/granc-core/tests/granc_client_reflection_test.rs new file mode 100644 index 0000000..1621cac --- /dev/null +++ b/granc-core/tests/granc_client_reflection_test.rs @@ -0,0 +1,214 @@ +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::reflection::client::ReflectionResolveError; +use tonic::Code; +use tonic::service::Routes; + +mod echo_service_impl; + +async fn setup_client() -> GrancClient> { + let reflection_service = tonic_reflection::server::Builder::configure() + .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET) + .build_v1() + .unwrap(); + + let echo_service = EchoServiceServer::new(EchoServiceImpl); + + let service = Routes::new(reflection_service).add_service(echo_service); + + GrancClient::from_service(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!( + services.as_slice(), + ["echo.EchoService", "grpc.reflection.v1.ServerReflection"] + ); +} + +#[tokio::test] +async fn test_reflection_describe_descriptors() { + 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" }), + headers: vec![], + }; + + let res = client.dynamic(req).await.unwrap(); + + assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "hello")); + + // Server Streaming + let req = DynamicRequest { + service: "echo.EchoService".to_string(), + method: "ServerStreamingEcho".to_string(), + body: serde_json::json!({ "message": "stream" }), + headers: vec![], + }; + + 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" + )); +} + +#[tokio::test] +async fn test_reflection_dynamic_error_cases() { + let mut client = setup_client().await; + + // Invalid Service Name + let req = DynamicRequest { + service: "echo.GhostService".to_string(), + method: "UnaryEcho".to_string(), + body: serde_json::json!({}), + headers: vec![], + }; + + let result = client.dynamic(req).await; + + assert!(matches!( + result, + Err(with_server_reflection::DynamicCallError::ReflectionResolve( + ReflectionResolveError::ServerStreamFailure(status) + )) if status.code() == Code::NotFound + )); + + // Invalid Method Name + let req = DynamicRequest { + service: "echo.EchoService".to_string(), + method: "GhostMethod".to_string(), + body: serde_json::json!({}), + headers: vec![], + }; + + let result = client.dynamic(req).await; + + assert!(matches!( + result, + Err(with_server_reflection::DynamicCallError::DynamicCallError( + with_file_descriptor::DynamicCallError::MethodNotFound(name) + )) if name == "GhostMethod" + )); + + // Invalid JSON Structure (Streaming requires Array, Object provided) + // This triggers `DynamicCallError::InvalidInput` before the request is sent. + let req = DynamicRequest { + service: "echo.EchoService".to_string(), + method: "ClientStreamingEcho".to_string(), + body: serde_json::json!({ "message": "I should be an array" }), + headers: vec![], + }; + + let result = client.dynamic(req).await; + + assert!(matches!( + result, + Err(with_server_reflection::DynamicCallError::DynamicCallError( + with_file_descriptor::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. + let req = DynamicRequest { + service: "echo.EchoService".to_string(), + method: "UnaryEcho".to_string(), + body: serde_json::json!({ "non_existent_field": "oops" }), + headers: vec![], + }; + + let result = client.dynamic(req).await; + + assert!(matches!( + result, + Ok(DynamicResponse::Unary(Err(status))) if status.code() == Code::Internal + )); +} diff --git a/granc-core/tests/granc_client_test.rs b/granc-core/tests/granc_client_test.rs deleted file mode 100644 index ff2d0bd..0000000 --- a/granc-core/tests/granc_client_test.rs +++ /dev/null @@ -1,213 +0,0 @@ -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 { - 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" }); - - let request = DynamicRequest { - file_descriptor_set: Some(FILE_DESCRIPTOR_SET.to_vec()), - body: payload.clone(), - headers: vec![], - service: "echo.EchoService".to_string(), - method: "UnaryEcho".to_string(), - }; - - let mut client = GrancClient::new(EchoServiceServer::new(EchoServiceImpl)); - - let res = client.dynamic(request).await.unwrap(); - - match res { - DynamicResponse::Unary(Ok(value)) => assert_eq!(value, payload), - DynamicResponse::Unary(Err(_)) => { - panic!("Received error status for valid unary request") - } - _ => panic!("Received stream response for unary request"), - }; -} - -#[tokio::test] -async fn test_server_streaming() { - let payload = serde_json::json!({ "message": "stream" }); - - let request = DynamicRequest { - file_descriptor_set: Some(FILE_DESCRIPTOR_SET.to_vec()), - body: payload.clone(), - headers: vec![], - service: "echo.EchoService".to_string(), - method: "ServerStreamingEcho".to_string(), - }; - - let mut client = GrancClient::new(EchoServiceServer::new(EchoServiceImpl)); - - let res = client.dynamic(request).await.unwrap(); - - match res { - DynamicResponse::Streaming(Ok(elems)) => { - let results: Vec<_> = elems.into_iter().map(|r| r.unwrap()).collect(); - - assert_eq!(results.len(), 3); - assert_eq!(results[0]["message"], "stream - seq 0"); - assert_eq!(results[1]["message"], "stream - seq 1"); - assert_eq!(results[2]["message"], "stream - seq 2"); - } - DynamicResponse::Streaming(Err(_)) => { - panic!("Received error status for valid server streaming request") - } - _ => panic!("Received unary response for server streaming request"), - }; -} - -#[tokio::test] -async fn test_client_streaming() { - let payload = serde_json::json!([ - { "message": "A" }, - { "message": "B" }, - { "message": "C" } - ]); - - let request = DynamicRequest { - file_descriptor_set: Some(FILE_DESCRIPTOR_SET.to_vec()), - body: payload.clone(), - headers: vec![], - service: "echo.EchoService".to_string(), - method: "ClientStreamingEcho".to_string(), - }; - - let mut client = GrancClient::new(EchoServiceServer::new(EchoServiceImpl)); - - let res = client.dynamic(request).await.unwrap(); - - match res { - DynamicResponse::Unary(Ok(value)) => { - assert_eq!(value, serde_json::json!({"message": "ABC"})) - } - DynamicResponse::Unary(Err(_)) => { - panic!("Received error status for valid client stream request") - } - _ => panic!("Received stream response for client stream request"), - }; -} - -#[tokio::test] -async fn test_bidirectional_streaming() { - let payload = serde_json::json!([ - { "message": "Ping" }, - { "message": "Pong" } - ]); - - let request = DynamicRequest { - file_descriptor_set: Some(FILE_DESCRIPTOR_SET.to_vec()), - body: payload.clone(), - headers: vec![], - service: "echo.EchoService".to_string(), - method: "BidirectionalEcho".to_string(), - }; - - let mut client = GrancClient::new(EchoServiceServer::new(EchoServiceImpl)); - - let res = client.dynamic(request).await.unwrap(); - - match res { - DynamicResponse::Streaming(Ok(elems)) => { - let results: Vec<_> = elems.into_iter().map(|r| r.unwrap()).collect(); - - assert_eq!(results.len(), 2); - assert_eq!(results[0]["message"], "echo: Ping"); - assert_eq!(results[1]["message"], "echo: Pong"); - } - DynamicResponse::Streaming(Err(_)) => { - panic!("Received error status for valid bidirectional streaming request") - } - _ => panic!("Received unary response for bidirectional streaming request"), - }; -} - -#[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 = 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 = 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(_) - )); -} diff --git a/granc-core/tests/reflection_client_test.rs b/granc-core/tests/reflection_client_test.rs index fd933d1..9d3da4a 100644 --- a/granc-core/tests/reflection_client_test.rs +++ b/granc-core/tests/reflection_client_test.rs @@ -1,11 +1,11 @@ -use dummy_echo_service_impl::DummyEchoService; use echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET}; +use echo_service_impl::EchoServiceImpl; use granc_core::reflection::client::{ReflectionClient, ReflectionResolveError}; use prost_reflect::DescriptorPool; use tonic::Code; use tonic_reflection::server::v1::ServerReflectionServer; -mod dummy_echo_service_impl; +mod echo_service_impl; fn setup_reflection_client() -> ReflectionClient> { @@ -115,7 +115,7 @@ async fn test_reflection_service_not_found_error() { async fn test_server_does_not_support_reflection() { // Create a server that ONLY hosts the EchoService. // This server does NOT have the Reflection service registered. - let server = EchoServiceServer::new(DummyEchoService); + let server = EchoServiceServer::new(EchoServiceImpl); let mut client = ReflectionClient::new(server); // The client will attempt to call `/grpc.reflection.v1.ServerReflection/ServerReflectionInfo` on this service. diff --git a/granc/Cargo.toml b/granc/Cargo.toml index 6a0fa69..3b871b1 100644 --- a/granc/Cargo.toml +++ b/granc/Cargo.toml @@ -16,7 +16,6 @@ version = "0.5.1" [dependencies] clap = { version = "4.5.54", features = ["derive"] } colored = "3.1.1" -granc_core = "0.4.1" +granc_core = { path = "../granc-core" } serde_json = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } -tonic = { workspace = true } diff --git a/granc/src/cli.rs b/granc/src/cli.rs index 094e3c7..fbecab6 100644 --- a/granc/src/cli.rs +++ b/granc/src/cli.rs @@ -13,20 +13,19 @@ 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)] 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)] @@ -37,10 +36,6 @@ pub enum Commands { #[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, }, /// List available services diff --git a/granc/src/formatter.rs b/granc/src/formatter.rs index 3ddef3c..3881cfc 100644 --- a/granc/src/formatter.rs +++ b/granc/src/formatter.rs @@ -1,9 +1,12 @@ use colored::*; use granc_core::{ - client::{ClientConnectError, DynamicCallError, GetDescriptorError, ListServicesError}, - prost_reflect::{EnumDescriptor, Kind, MessageDescriptor, MethodDescriptor, ServiceDescriptor}, + client::{with_file_descriptor, with_server_reflection}, + prost_reflect::{ + self, EnumDescriptor, Kind, MessageDescriptor, MethodDescriptor, ServiceDescriptor, + }, + tonic::Status, }; -use tonic::Status; +use std::fmt::Display; /// A wrapper struct for a formatted, colored string. /// @@ -12,6 +15,8 @@ pub struct FormattedString(pub String); pub struct ServiceList(pub Vec); +pub struct GenericError(pub &'static str, pub T); + impl std::fmt::Display for FormattedString { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { writeln!(f)?; @@ -37,6 +42,30 @@ impl From for FormattedString { } } +// Error from Reflection-based calls +impl From for FormattedString { + fn from(err: with_server_reflection::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 { + FormattedString(format!("{}\n\n'{}'", "Call Failed:".red().bold(), err)) + } +} + +impl From for FormattedString { + fn from(err: prost_reflect::DescriptorError) -> Self { + FormattedString(format!( + "{}\n\n'{}'", + "Failed to parse file descriptor:".red().bold(), + err + )) + } +} + impl From for FormattedString { fn from(err: std::io::Error) -> Self { FormattedString(format!( @@ -47,24 +76,20 @@ impl From for FormattedString { } } -impl From for FormattedString { - fn from(err: ClientConnectError) -> Self { +impl From> for FormattedString { + fn from(GenericError(msg, err): GenericError) -> Self { + FormattedString(format!("{}:\n\n'{}'", msg.red().bold(), err)) + } +} + +impl From for FormattedString { + fn from(err: with_server_reflection::ClientConnectError) -> Self { FormattedString(format!("{}\n\n'{}'", "Connection Error:".red().bold(), err)) } } -impl From for FormattedString { - fn from(err: ListServicesError) -> Self { - FormattedString(format!( - "{}\n\n'{}'", - "Failed to list services:".red().bold(), - err - )) - } -} - -impl From for FormattedString { - fn from(err: GetDescriptorError) -> Self { +impl From for FormattedString { + fn from(err: with_server_reflection::GetDescriptorError) -> Self { FormattedString(format!( "{}\n\n'{}'", "Symbol Lookup Failed:".red().bold(), @@ -73,12 +98,6 @@ impl From for FormattedString { } } -impl From for FormattedString { - fn from(err: DynamicCallError) -> Self { - FormattedString(format!("{}\n\n'{}'", "Call Failed:".red().bold(), err)) - } -} - impl From for FormattedString { fn from(ServiceList(services): ServiceList) -> Self { if services.is_empty() { diff --git a/granc/src/main.rs b/granc/src/main.rs index 81d1867..c96d5a5 100644 --- a/granc/src/main.rs +++ b/granc/src/main.rs @@ -4,7 +4,7 @@ //! //! 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`. +//! 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. mod cli; @@ -12,122 +12,140 @@ mod formatter; use clap::Parser; use cli::{Cli, Commands}; -use formatter::FormattedString; -use granc_core::client::{Descriptor, DynamicRequest, DynamicResponse, GrancClient}; +use formatter::{FormattedString, GenericError, ServiceList}; +use granc_core::client::{ + Descriptor, DynamicRequest, DynamicResponse, GrancClient, WithFileDescriptor, + WithServerReflection, +}; +use granc_core::tonic::transport::Channel; 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; - match args.command { + let client = unwrap_or_exit(GrancClient::connect(&args.url).await); + + if let Some(path) = args.file_descriptor_set { + let bytes = unwrap_or_exit(std::fs::read(&path)); + let client = unwrap_or_exit(client.with_file_descriptor(bytes)); + handle_file_descriptor_mode(client, args.command).await; + } else { + handle_reflection_mode(client, args.command).await; + } +} + +async fn handle_reflection_mode( + mut client: GrancClient>, + command: Commands, +) { + match command { Commands::Call { endpoint, body, headers, - file_descriptor_set, } => { let (service, method) = endpoint; - run_call(url, service, method, body, headers, file_descriptor_set).await; + let request = DynamicRequest { + body, + headers, + service, + method, + }; + + let response = unwrap_or_exit(client.dynamic(request).await); + print_response(response); } - 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) => { + 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))); } - Err(e) => { - eprintln!("{}", FormattedString::from(e)); - process::exit(1); + Commands::Describe { symbol } => { + let descriptor = unwrap_or_exit(client.get_descriptor_by_symbol(&symbol).await); + print_descriptor(descriptor); } } } -async fn describe_type(url: &str, symbol: &str) { - let mut client = connect_or_exit(url).await; +// --- Handler for File Descriptor Mode --- - 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, +async fn handle_file_descriptor_mode( + mut client: GrancClient>, + command: Commands, ) { - let file_descriptor_set = match file_descriptor_set.map(std::fs::read).transpose() { - Ok(fd) => fd, - Err(err) => { - eprintln!("{}", FormattedString::from(err)); + match command { + Commands::Call { + endpoint, + body, + headers, + } => { + let (service, method) = endpoint; + let request = DynamicRequest { + body, + headers, + service, + method, + }; + + let response = unwrap_or_exit(client.dynamic(request).await); + print_response(response); + } + Commands::List => { + let services = client.list_services(); + println!("{}", FormattedString::from(ServiceList(services))); + } + Commands::Describe { symbol } => { + let descriptor = unwrap_or_exit( + client + .get_descriptor_by_symbol(&symbol) + .ok_or(GenericError("Symbol not found", symbol)), + ); + print_descriptor(descriptor); + } + } +} + +/// Helper function to return the Ok value or print the error and exit. +fn unwrap_or_exit(result: Result) -> T +where + E: Into, +{ + match result { + Ok(v) => v, + Err(e) => { + eprintln!("{}", Into::::into(e)); process::exit(1); } - }; + } +} - let request = DynamicRequest { - file_descriptor_set, - body, - headers, - service, - method, - }; +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)), + } +} - let mut client = connect_or_exit(&url).await; - - match client.dynamic(request).await { - Ok(DynamicResponse::Unary(Ok(value))) => println!("{}", FormattedString::from(value)), - Ok(DynamicResponse::Unary(Err(status))) => println!("{}", FormattedString::from(status)), - Ok(DynamicResponse::Streaming(Ok(values))) => print_stream(&values), - Ok(DynamicResponse::Streaming(Err(status))) => { +fn print_response(response: DynamicResponse) { + match response { + DynamicResponse::Unary(Ok(value)) => println!("{}", FormattedString::from(value)), + DynamicResponse::Unary(Err(status)) => println!("{}", FormattedString::from(status)), + DynamicResponse::Streaming(Ok(values)) => { + for elem in values { + match elem { + Ok(val) => println!("{}", FormattedString::from(val)), + Err(status) => println!("{}", FormattedString::from(status)), + } + } + } + DynamicResponse::Streaming(Err(status)) => { println!("{}", FormattedString::from(status)) } - Err(err) => { - eprintln!("{}", FormattedString::from(err)); - process::exit(1); - } - } -} - -fn print_stream(stream: &[Result]) { - for elem in stream { - match elem { - Ok(val) => println!("{}", FormattedString::from(val.clone())), - Err(status) => println!("{}", FormattedString::from(status.clone())), - } } }