mirror of
https://codeberg.org/JasterV/granc.git
synced 2026-04-26 18:40:05 +00:00
[fix] A URL should not be required for list and describe commands (#35)
solves #34
This commit is contained in:
parent
8ce153e271
commit
9990e94c8c
16 changed files with 1121 additions and 762 deletions
22
Cargo.lock
generated
22
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
92
README.md
92
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 <URL> [OPTIONS] <COMMAND> [ARGS]
|
||||
granc <COMMAND> [ARGS]
|
||||
|
||||
```
|
||||
|
||||
### Global Arguments
|
||||
|
||||
| Argument | Description | Required |
|
||||
| --- | --- | --- |
|
||||
| `<URL>` | Server address (e.g., `http://[::1]:50051`). Must be the first argument. | **Yes** |
|
||||
| `--file-descriptor-set` | Path to the binary FileDescriptorSet (`.bin`). If omitted, Granc attempts to use Server Reflection. | No |
|
||||
|
||||
### Commands
|
||||
|
||||
#### 1. `call` (Make Requests)
|
||||
|
|
@ -76,59 +71,97 @@ granc <URL> [OPTIONS] <COMMAND> [ARGS]
|
|||
Performs a gRPC call using a JSON body.
|
||||
|
||||
```bash
|
||||
granc http://localhost:50051 [OPTIONS] call <ENDPOINT> --body <JSON> [ARGS]
|
||||
granc call <ENDPOINT> --uri <URI> --body <JSON> [OPTIONS]
|
||||
|
||||
```
|
||||
|
||||
| Argument/Flag | Description | Required |
|
||||
| --- | --- | --- |
|
||||
| `<ENDPOINT>` | Fully qualified method name (e.g., `my.package.Service/Method`). | **Yes** |
|
||||
| `--body` | The request body in JSON format. Object `{}` for unary, Array `[]` for streaming. | **Yes** |
|
||||
| `--header`, `-H` | Custom header `key:value`. Can be used multiple times. | No |
|
||||
| Argument/Flag | Short | Description | Required |
|
||||
| --- | --- | --- | --- |
|
||||
| `<ENDPOINT>` | | Fully qualified method name (e.g., `my.package.Service/Method`). | **Yes** |
|
||||
| `--uri` | `-u` | Server address (e.g., `http://[::1]:50051`). | **Yes** |
|
||||
| `--body` | `-b` | The request body in JSON format. Object `{}` for unary, Array `[]` for streaming. | **Yes** |
|
||||
| `--header` | `-H` | Custom header `key:value`. Can be used multiple times. | No |
|
||||
| `--file-descriptor-set` | `-f` | Path to a local `.bin` descriptor file to use instead of reflection. | No |
|
||||
|
||||
**Example using Server Reflection:**
|
||||
|
||||
```bash
|
||||
granc http://localhost:50051 call helloworld.Greeter/SayHello --body '{"name": "Ferris"}'
|
||||
granc call helloworld.Greeter/SayHello --uri http://localhost:50051 --body '{"name": "Ferris"}'
|
||||
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Hello Ferris"
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**Example using a Local Descriptor File:**
|
||||
|
||||
```bash
|
||||
granc http://localhost:50051 --file-descriptor-set ./descriptors.bin call helloworld.Greeter/SayHello --body '{"name": "Ferris"}'
|
||||
granc call helloworld.Greeter/SayHello \
|
||||
--uri http://localhost:50051 \
|
||||
--file-descriptor-set ./descriptors.bin \
|
||||
--body '{"name": "Ferris"}'
|
||||
|
||||
```
|
||||
|
||||
#### 2. `list` (Service Discovery)
|
||||
|
||||
Lists all services exposed by the server (via reflection) or contained in the provided descriptor file.
|
||||
Lists all services exposed by the server (via reflection) or contained in the provided descriptor file. You must provide **either** a URI or a file descriptor set.
|
||||
|
||||
```bash
|
||||
granc http://localhost:50051 list
|
||||
granc list [OPTIONS]
|
||||
|
||||
```
|
||||
|
||||
| Flag | Short | Description |
|
||||
| --- | --- | --- |
|
||||
| `--uri` | `-u` | Use Server Reflection to list available services. |
|
||||
| `--file-descriptor-set` | `-f` | Use a local file to list contained services (offline). |
|
||||
|
||||
**Listing services via Reflection:**
|
||||
|
||||
```bash
|
||||
granc list --uri http://localhost:50051
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
Available Services:
|
||||
- grpc.reflection.v1.ServerReflection
|
||||
- helloworld.Greeter
|
||||
|
||||
```
|
||||
|
||||
**Listing services from a file:**
|
||||
**Listing services from a file (Offline):**
|
||||
|
||||
```bash
|
||||
granc http://localhost:50051 --file-descriptor-set ./descriptors.bin list
|
||||
granc list --file-descriptor-set ./descriptors.bin
|
||||
|
||||
```
|
||||
|
||||
#### 3. `describe` (Introspection)
|
||||
|
||||
Inspects a specific symbol (Service, Message, or Enum) and prints its Protobuf definition in a colored, human-readable format.
|
||||
Inspects a specific symbol (Service, Message, or Enum) and prints its Protobuf definition in a colored, human-readable format. You must provide **either** a URI or a file descriptor set.
|
||||
|
||||
```bash
|
||||
granc http://localhost:50051 describe helloworld.Greeter
|
||||
granc describe <SYMBOL> [OPTIONS]
|
||||
|
||||
```
|
||||
|
||||
| Argument/Flag | Short | Description |
|
||||
| --- | --- | --- |
|
||||
| `<SYMBOL>` | | Fully qualified name of the Service, Message, or Enum. |
|
||||
| `--uri` | `-u` | Use Server Reflection to resolve the symbol. |
|
||||
| `--file-descriptor-set` | `-f` | Use a local file to resolve the symbol (offline). |
|
||||
|
||||
**Describing a Service via Reflection:**
|
||||
|
||||
```bash
|
||||
granc describe helloworld.Greeter --uri http://localhost:50051
|
||||
|
||||
```
|
||||
|
||||
```proto
|
||||
|
|
@ -136,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
|
||||
|
|
|
|||
|
|
@ -10,13 +10,15 @@ Instead of strictly typed Rust structs, this library bridges standard `serde_jso
|
|||
|
||||
## 🚀 High-Level Usage
|
||||
|
||||
The primary entry point is the [`GrancClient`]. It acts as an orchestrator that connects to a gRPC server and provides methods for both executing requests and inspecting the server's schema.
|
||||
The primary entry point is the [`GrancClient`]. It uses a **Typestate Pattern** to ensure safety and correctness regarding how the Protobuf schema is resolved. There are three distinct states:
|
||||
|
||||
To ensure safety and correctness, `GrancClient` uses a **Typestate Pattern**. It starts in a state that relies on Server Reflection, but can transition to a state that uses a local `FileDescriptorSet`.
|
||||
1. **[`Online`]**: Connected to a server, uses Server Reflection (Async introspection).
|
||||
2. **[`OnlineWithoutReflection`]**: Connected to a server, uses a local `FileDescriptorSet` (Sync introspection).
|
||||
3. **[`Offline`]**: Disconnected, uses a local `FileDescriptorSet` (Sync introspection).
|
||||
|
||||
### 1. Using Server Reflection (Default)
|
||||
### 1. Online (Server Reflection)
|
||||
|
||||
By default, when you connect, the client is ready to use the server's reflection service to resolve methods and types dynamically.
|
||||
This is the default state when you connect. The client queries the server's reflection endpoint to dynamically discover services and message formats.
|
||||
|
||||
```rust
|
||||
use granc_core::client::{GrancClient, DynamicRequest, DynamicResponse};
|
||||
|
|
@ -24,91 +26,81 @@ use serde_json::json;
|
|||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Connect (starts in Reflection mode)
|
||||
// 1. Connect (Starts in 'Online' state)
|
||||
let mut client = GrancClient::connect("http://localhost:50051").await?;
|
||||
|
||||
// 2. Introspection (Async via Reflection)
|
||||
let services = client.list_services().await?;
|
||||
println!("Server services: {:?}", services);
|
||||
|
||||
// 3. Dynamic Call
|
||||
let request = DynamicRequest {
|
||||
service: "helloworld.Greeter".to_string(),
|
||||
method: "SayHello".to_string(),
|
||||
body: json!({ "name": "World" }),
|
||||
body: json!({ "name": "Ferris" }),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
// Execute (Schema is fetched automatically via reflection)
|
||||
// Schema is fetched automatically from the server
|
||||
let response = client.dynamic(request).await?;
|
||||
|
||||
match response {
|
||||
DynamicResponse::Unary(Ok(value)) => println!("Response: {}", value),
|
||||
DynamicResponse::Unary(Err(status)) => eprintln!("gRPC Error: {:?}", status),
|
||||
_ => {}
|
||||
}
|
||||
println!("{:?}", response);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 2. Using a Local Descriptor File
|
||||
### 2. OnlineWithoutReflection (Local Schema)
|
||||
|
||||
If you have a `.bin` file generated by `protoc`, you can load it into the client. This transforms the client's state, disabling reflection and forcing it to look up schemas in the provided file.
|
||||
Use this state if you are connecting to a server that does not support reflection, or if you want to enforce a specific schema version from a local file.
|
||||
|
||||
```rust
|
||||
use granc_core::client::GrancClient;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Connect (starts in Reflection mode)
|
||||
let client = GrancClient::connect("http://localhost:50051").await?;
|
||||
|
||||
// Read the descriptor file
|
||||
let descriptor_bytes = std::fs::read("descriptor.bin")?;
|
||||
|
||||
// Transition to File Descriptor mode
|
||||
// Transition state: Online -> OnlineWithoutReflection
|
||||
let mut client = client.with_file_descriptor(descriptor_bytes)?;
|
||||
|
||||
// Now use this client for requests. It will NOT query the server for schema.
|
||||
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<Descriptor, Error>
|
||||
let descriptor = client.get_descriptor_by_symbol("helloworld.Greeter").await?;
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let descriptor_bytes = std::fs::read("descriptor.bin")?;
|
||||
|
||||
match descriptor {
|
||||
Descriptor::ServiceDescriptor(svc) => println!("Service: {}", svc.name()),
|
||||
Descriptor::MessageDescriptor(msg) => println!("Message: {}", msg.name()),
|
||||
Descriptor::EnumDescriptor(enm) => println!("Enum: {}", enm.name()),
|
||||
// Create directly in 'Offline' state
|
||||
let client = GrancClient::offline(descriptor_bytes)?;
|
||||
|
||||
// Sync introspection methods
|
||||
let services = client.list_services();
|
||||
|
||||
if let Some(descriptor) = client.get_descriptor_by_symbol("helloworld.Greeter") {
|
||||
println!("Found service: {:?}", descriptor);
|
||||
}
|
||||
|
||||
// Note: client.dynamic() is NOT available in this state.
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
#### Using Local File (Sync)
|
||||
|
||||
```rust
|
||||
// List available services (immediate, can't fail)
|
||||
let services = client_fd.list_services();
|
||||
|
||||
// Get a specific descriptor (immediate)
|
||||
// Returns Option<Descriptor>
|
||||
if let Some(descriptor) = client_fd.get_descriptor_by_symbol("helloworld.Greeter") {
|
||||
println!("Found symbol: {:?}", descriptor);
|
||||
} else {
|
||||
println!("Symbol not found in file");
|
||||
}
|
||||
```
|
||||
|
||||
## 🛠️ Internal Components
|
||||
|
|
@ -117,39 +109,15 @@ We expose the internal building blocks of `granc` for developers who need more g
|
|||
|
||||
### 1. `GrpcClient` (Generic Transport)
|
||||
|
||||
Standard `tonic` clients are strongly typed (e.g., `client.say_hello(HelloRequest)`).
|
||||
`GrpcClient` is a generic wrapper around `tonic::client::Grpc` that works strictly with `serde_json::Value` and `prost_reflect::MethodDescriptor`.
|
||||
|
||||
It handles the raw HTTP/2 path construction and metadata mapping, providing specific methods for all four gRPC access patterns:
|
||||
|
||||
* `unary`
|
||||
* `server_streaming`
|
||||
* `client_streaming`
|
||||
* `bidirectional_streaming`
|
||||
|
||||
```rust
|
||||
use granc_core::grpc::client::GrpcClient;
|
||||
// You need a method_descriptor from prost_reflect::DescriptorPool
|
||||
// let method_descriptor = ...;
|
||||
|
||||
let mut grpc = GrpcClient::new(channel);
|
||||
let result = grpc.unary(method_descriptor, json_value, headers).await?;
|
||||
|
||||
```
|
||||
Standard `tonic` clients are strongly typed. `GrpcClient` is a generic wrapper around `tonic::client::Grpc` that works strictly with `serde_json::Value` and `prost_reflect::MethodDescriptor`. It handles the raw HTTP/2 path construction and metadata mapping.
|
||||
|
||||
### 2. `JsonCodec`
|
||||
|
||||
The magic behind the dynamic serialization. This implementation of `tonic::codec::Codec` validates and transcodes JSON to Protobuf bytes (and vice versa) on the fly.
|
||||
|
||||
* **Encoder**: Validates `serde_json::Value` against the input `MessageDescriptor` and serializes it.
|
||||
* **Decoder**: Deserializes bytes into a `DynamicMessage` and converts it back to `serde_json::Value`.
|
||||
|
||||
### 3. `ReflectionClient`
|
||||
|
||||
A client for `grpc.reflection.v1`. It enables runtime schema discovery.
|
||||
|
||||
The `ReflectionClient` is smart enough to handle dependencies. When you ask for a symbol (e.g., `my.package.Service`),
|
||||
it recursively fetches the file defining that symbol and **all** its transitive imports, building a complete `prost_types::FileDescriptorSet` ready for use. It also supports listing available services.
|
||||
A robust client for `grpc.reflection.v1`. It automatically handles transitive dependency resolution, recursively fetching all imported files to build a complete, self-contained `FileDescriptorSet`.
|
||||
|
||||
## ⚖️ License
|
||||
|
||||
|
|
|
|||
|
|
@ -3,14 +3,15 @@
|
|||
//! This module implements the high-level logic for executing dynamic gRPC requests.
|
||||
//!
|
||||
//! The [`GrancClient`] uses a **Typestate Pattern** to ensure safety and correctness regarding
|
||||
//! how the Protobuf schema is resolved. It has two possible states:
|
||||
//! how the Protobuf schema is resolved. It has three possible states:
|
||||
//!
|
||||
//! 1. **[`WithServerReflection`]**: The default state. The client is connected
|
||||
//! to a server and uses the gRPC Server Reflection Protocol (`grpc.reflection.v1`) to discover
|
||||
//! services and fetch schemas on the fly.
|
||||
//! 2. **[`WithFileDescriptor`]**: The client has been provided with a specific
|
||||
//! binary `FileDescriptorSet` (e.g., loaded from a `.bin` file). In this state, reflection is
|
||||
//! disabled, and all lookups are performed against the provided file.
|
||||
//! 1. **[`Online`]**: The default state when connecting. The client uses the gRPC
|
||||
//! Server Reflection Protocol (`grpc.reflection.v1`) to discover services.
|
||||
//! 2. **[`OnlineWithoutReflection`]**: The client is connected to a server but uses a local
|
||||
//! binary `FileDescriptorSet` for schema lookups.
|
||||
//! 3. **[`Offline`]**: The client is **not connected** to any server. It holds a
|
||||
//! local `FileDescriptorSet` and can only be used for introspection (Listing services, describing symbols),
|
||||
//! but cannot perform gRPC calls.
|
||||
//!
|
||||
//! ## Example: State Transition
|
||||
//!
|
||||
|
|
@ -18,109 +19,89 @@
|
|||
//! use granc_core::client::GrancClient;
|
||||
//!
|
||||
//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! // Connect (starts in Reflection state)
|
||||
//! let mut client_reflection = GrancClient::connect("http://localhost:50051").await?;
|
||||
//! // 1. Online State (Reflection)
|
||||
//! let mut client = GrancClient::connect("http://localhost:50051").await?;
|
||||
//!
|
||||
//! // The API here is async
|
||||
//! let services = client_reflection.list_services().await?;
|
||||
//!
|
||||
//! // 2Transition to File Descriptor state
|
||||
//! // 2. Transition to OnlineWithoutReflection (Connected + Local Schema)
|
||||
//! let bytes = std::fs::read("descriptor.bin")?;
|
||||
//! let mut client_fd = client_reflection.with_file_descriptor(bytes)?;
|
||||
//! let mut client_static = client.with_file_descriptor(bytes)?;
|
||||
//!
|
||||
//! // Now operations use the local file and are sync
|
||||
//! let services = client_fd.list_services();
|
||||
//! // 3. Offline State (Disconnected + Local Schema)
|
||||
//! let bytes = std::fs::read("descriptor.bin")?;
|
||||
//! let mut client_offline = GrancClient::offline(bytes)?;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
pub mod with_file_descriptor;
|
||||
pub mod with_server_reflection;
|
||||
pub mod offline;
|
||||
pub mod online;
|
||||
pub mod online_without_reflection;
|
||||
mod types;
|
||||
|
||||
pub use types::*;
|
||||
|
||||
use crate::{grpc::client::GrpcClient, reflection::client::ReflectionClient};
|
||||
use prost_reflect::{DescriptorPool, EnumDescriptor, MessageDescriptor, ServiceDescriptor};
|
||||
use prost_reflect::DescriptorPool;
|
||||
use std::fmt::Debug;
|
||||
use tonic::transport::Channel;
|
||||
|
||||
/// The main client for interacting with gRPC servers dynamically.
|
||||
///
|
||||
/// The generic parameter `T` represents the current state of the client, determining
|
||||
/// its capabilities and how it resolves Protobuf schemas.
|
||||
/// The generic parameter `T` represents the current state of the client.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GrancClient<T> {
|
||||
state: T,
|
||||
}
|
||||
|
||||
/// The state for a client that uses a local `DescriptorPool` for schema resolution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WithFileDescriptor<S = Channel> {
|
||||
grpc_client: GrpcClient<S>,
|
||||
pool: DescriptorPool,
|
||||
impl<T> GrancClient<T> {
|
||||
pub(crate) fn new(state: T) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
/// The state for a client that uses Server Reflection for schema resolution.
|
||||
/// State: Connected to server, Schema resolved from Server Reflection.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WithServerReflection<S = Channel> {
|
||||
pub struct Online<S = Channel> {
|
||||
reflection_client: ReflectionClient<S>,
|
||||
grpc_client: GrpcClient<S>,
|
||||
}
|
||||
|
||||
/// A request object encapsulating all necessary information to perform a dynamic gRPC call.
|
||||
/// State: Connected to server, Schema resolved from local FileDescriptor.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DynamicRequest {
|
||||
/// The JSON body of the request.
|
||||
/// - For Unary/ServerStreaming: An Object `{}`.
|
||||
/// - For ClientStreaming/Bidirectional: An Array of Objects `[{}]`.
|
||||
pub body: serde_json::Value,
|
||||
/// Custom gRPC metadata (headers) to attach to the request.
|
||||
pub headers: Vec<(String, String)>,
|
||||
/// The fully qualified name of the service (e.g., `my.package.Service`).
|
||||
pub service: String,
|
||||
/// The name of the method to call (e.g., `SayHello`).
|
||||
pub method: String,
|
||||
pub struct OnlineWithoutReflection<S = Channel> {
|
||||
grpc_client: GrpcClient<S>,
|
||||
pool: DescriptorPool,
|
||||
}
|
||||
|
||||
/// The result of a dynamic gRPC call.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DynamicResponse {
|
||||
/// A single response message (for Unary and Client Streaming calls).
|
||||
Unary(Result<serde_json::Value, tonic::Status>),
|
||||
/// A stream of response messages (for Server Streaming and Bidirectional calls).
|
||||
Streaming(Result<Vec<Result<serde_json::Value, tonic::Status>>, tonic::Status>),
|
||||
}
|
||||
|
||||
/// A generic wrapper for different types of Protobuf descriptors.
|
||||
///
|
||||
/// This enum allows the client to return a single type when resolving symbols,
|
||||
/// regardless of whether the symbol points to a Service, a Message, or an Enum.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Descriptor {
|
||||
MessageDescriptor(MessageDescriptor),
|
||||
ServiceDescriptor(ServiceDescriptor),
|
||||
EnumDescriptor(EnumDescriptor),
|
||||
}
|
||||
|
||||
impl Descriptor {
|
||||
/// Returns the inner [`MessageDescriptor`] if this variant is `MessageDescriptor`.
|
||||
pub fn message_descriptor(&self) -> Option<&MessageDescriptor> {
|
||||
match self {
|
||||
Descriptor::MessageDescriptor(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<S> OnlineWithoutReflection<S> {
|
||||
pub(crate) fn new(grpc_client: GrpcClient<S>, pool: DescriptorPool) -> Self {
|
||||
Self { pool, grpc_client }
|
||||
}
|
||||
}
|
||||
|
||||
/// State: Disconnected, Schema resolved from local FileDescriptor.
|
||||
#[derive(Debug, Clone)]
|
||||
pub 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<S> OfflineReflectionState for OnlineWithoutReflection<S> {
|
||||
fn descriptor_pool(&self) -> &DescriptorPool {
|
||||
&self.pool
|
||||
}
|
||||
}
|
||||
|
|
|
|||
73
granc-core/src/client/offline.rs
Normal file
73
granc-core/src/client/offline.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
//! # Client State: Offline
|
||||
//!
|
||||
//! This module defines the `GrancClient` behavior when it is using a local, in-memory
|
||||
//! `DescriptorPool` but is **not connected** to any gRPC server.
|
||||
//!
|
||||
//! In this state, the client is strictly limited to introspection tasks.
|
||||
use super::{GrancClient, Offline};
|
||||
use crate::client::{OfflineReflectionState, types::Descriptor};
|
||||
use prost_reflect::{DescriptorError, DescriptorPool};
|
||||
|
||||
impl GrancClient<Offline> {
|
||||
/// Creates a new `GrancClient` in the Offline state using a raw byte buffer
|
||||
/// containing a `FileDescriptorSet`.
|
||||
///
|
||||
/// This client starts in a **disconnected** state. It can be used to inspect the
|
||||
/// provided schema but cannot make network requests.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `file_descriptor` - A vector of bytes containing the encoded `FileDescriptorSet`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(GrancClient<Offline>)` - The initialized offline client.
|
||||
/// * `Err(DescriptorError)` - If the bytes are not a valid descriptor set.
|
||||
pub fn offline(file_descriptor: Vec<u8>) -> Result<Self, DescriptorError> {
|
||||
let pool = DescriptorPool::decode(file_descriptor.as_slice())?;
|
||||
Ok(GrancClient::new(Offline::new(pool)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> GrancClient<T>
|
||||
where
|
||||
T: OfflineReflectionState,
|
||||
{
|
||||
/// Lists all services defined in the local `DescriptorPool`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A list of fully qualified service names (e.g. `helloworld.Greeter`).
|
||||
pub fn list_services(&self) -> Vec<String> {
|
||||
self.state
|
||||
.descriptor_pool()
|
||||
.services()
|
||||
.map(|s| s.full_name().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Looks up a specific symbol in the local `DescriptorPool`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `symbol` - The fully qualified name (Service, Message, or Enum).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Some(Descriptor)` - The resolved descriptor if found.
|
||||
/// * `None` - If the symbol does not exist in the pool.
|
||||
pub fn get_descriptor_by_symbol(&self, symbol: &str) -> Option<Descriptor> {
|
||||
let pool = self.state.descriptor_pool();
|
||||
|
||||
if let Some(descriptor) = pool.get_service_by_name(symbol) {
|
||||
return Some(Descriptor::ServiceDescriptor(descriptor));
|
||||
}
|
||||
if let Some(descriptor) = pool.get_message_by_name(symbol) {
|
||||
return Some(Descriptor::MessageDescriptor(descriptor));
|
||||
}
|
||||
if let Some(descriptor) = pool.get_enum_by_name(symbol) {
|
||||
return Some(Descriptor::EnumDescriptor(descriptor));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +1,45 @@
|
|||
//! # Client State: Server Reflection
|
||||
//! # Client State: Online (With Server Reflection)
|
||||
//!
|
||||
//! This module defines the `GrancClient` behavior when it is using the server's reflection service
|
||||
//! to resolve schemas.
|
||||
//! This module defines the `GrancClient` behavior when it is connected to a server
|
||||
//! and using Server Reflection for schema resolution.
|
||||
use super::{
|
||||
Descriptor, DynamicRequest, DynamicResponse, GrancClient, WithFileDescriptor,
|
||||
WithServerReflection,
|
||||
Descriptor, DynamicRequest, DynamicResponse, GrancClient, Online, OnlineWithoutReflection,
|
||||
};
|
||||
use crate::{
|
||||
BoxError,
|
||||
client::Offline,
|
||||
grpc::client::GrpcClient,
|
||||
reflection::client::{ReflectionClient, ReflectionResolveError},
|
||||
};
|
||||
use http_body::Body as HttpBody;
|
||||
use prost_reflect::DescriptorError;
|
||||
use prost_reflect::DescriptorPool;
|
||||
use prost_reflect::{DescriptorError, DescriptorPool};
|
||||
use std::fmt::Debug;
|
||||
use tonic::{
|
||||
Code,
|
||||
transport::{Channel, Endpoint},
|
||||
};
|
||||
|
||||
/// Errors that can occur when connecting to a gRPC server.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ClientConnectError {
|
||||
#[error("Invalid URL '{0}': {1}")]
|
||||
InvalidUrl(String, #[source] tonic::transport::Error),
|
||||
#[error("Invalid URI '{0}': {1}")]
|
||||
InvalidUri(String, #[source] tonic::transport::Error),
|
||||
#[error("Failed to connect to '{0}': {1}")]
|
||||
ConnectionFailed(String, #[source] tonic::transport::Error),
|
||||
}
|
||||
|
||||
/// Errors that can occur during a dynamic call in Online mode.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DynamicCallError {
|
||||
#[error("Reflection resolution failed: '{0}'")]
|
||||
ReflectionResolve(#[from] ReflectionResolveError),
|
||||
|
||||
#[error("Failed to decode file descriptor set: '{0}'")]
|
||||
DescriptorError(#[from] DescriptorError),
|
||||
|
||||
#[error(transparent)]
|
||||
DynamicCallError(#[from] super::with_file_descriptor::DynamicCallError),
|
||||
DynamicCallError(#[from] super::online_without_reflection::DynamicCallError),
|
||||
}
|
||||
|
||||
/// Errors that can occur when looking up a descriptor in Online mode.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GetDescriptorError {
|
||||
#[error("Reflection resolution failed: '{0}'")]
|
||||
|
|
@ -50,60 +50,66 @@ pub enum GetDescriptorError {
|
|||
NotFound(String),
|
||||
}
|
||||
|
||||
impl GrancClient<WithServerReflection<Channel>> {
|
||||
/// Connects to a gRPC server at the specified address.
|
||||
impl GrancClient<Online<Channel>> {
|
||||
/// Connects to a gRPC server and initializes the client in the `Online` state.
|
||||
///
|
||||
/// This initializes the client in the **Reflection** state. It establishes a TCP connection
|
||||
/// but does not yet perform any reflection calls.
|
||||
/// This is the entry point for interacting with a server. By default, the client assumes
|
||||
/// the server supports the gRPC Server Reflection Protocol.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `addr` - The URI of the server (e.g., `http://localhost:50051`).
|
||||
/// * `addr` - The server URI (e.g., `http://localhost:50051`).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(GrancClient<WithServerReflection>)` - The connected client ready to use reflection.
|
||||
/// * `Err(ClientConnectError)` - If the URL is invalid or the connection cannot be established.
|
||||
/// * `Ok(GrancClient<Online>)` - A connected client ready to make dynamic requests via reflection.
|
||||
/// * `Err(ClientConnectError)` - If the URI is invalid or the TCP connection cannot be established.
|
||||
pub async fn connect(addr: &str) -> Result<Self, ClientConnectError> {
|
||||
let endpoint = Endpoint::new(addr.to_string())
|
||||
.map_err(|e| ClientConnectError::InvalidUrl(addr.to_string(), e))?;
|
||||
.map_err(|e| ClientConnectError::InvalidUri(addr.to_string(), e))?;
|
||||
|
||||
let channel = endpoint
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| ClientConnectError::ConnectionFailed(addr.to_string(), e))?;
|
||||
|
||||
Ok(Self::from_service(channel))
|
||||
Ok(GrancClient::from(channel))
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> GrancClient<WithServerReflection<S>>
|
||||
impl<S> From<S> for GrancClient<Online<S>>
|
||||
where
|
||||
S: tonic::client::GrpcService<tonic::body::Body> + Clone,
|
||||
S::ResponseBody: HttpBody<Data = tonic::codegen::Bytes> + Send + 'static,
|
||||
<S::ResponseBody as HttpBody>::Error: Into<BoxError> + Send,
|
||||
{
|
||||
/// Creates a new `GrancClient` wrapping an existing Tonic service (e.g., a `Channel` or `InterceptedService`).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `service` - The generic gRPC service implementation to use for transport.
|
||||
pub fn from_service(service: S) -> Self {
|
||||
fn from(service: S) -> Self {
|
||||
let reflection_client = ReflectionClient::new(service.clone());
|
||||
let grpc_client = GrpcClient::new(service);
|
||||
|
||||
Self {
|
||||
state: WithServerReflection {
|
||||
state: Online {
|
||||
reflection_client,
|
||||
grpc_client,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transitions the client to the **File Descriptor** state.
|
||||
impl<S> GrancClient<Online<S>>
|
||||
where
|
||||
S: tonic::client::GrpcService<tonic::body::Body> + Clone,
|
||||
S::ResponseBody: HttpBody<Data = tonic::codegen::Bytes> + Send + 'static,
|
||||
<S::ResponseBody as HttpBody>::Error: Into<BoxError> + Send,
|
||||
{
|
||||
/// Transitions the client to the **OnlineWithoutReflection** state by loading a local descriptor.
|
||||
///
|
||||
/// This method consumes the current client and returns a new client that uses the provided
|
||||
/// binary `FileDescriptorSet` for all schema lookups, disabling server reflection.
|
||||
/// This methods consumes the current client and returns a new one that:
|
||||
/// 1. Retains the existing network connection.
|
||||
/// 2. Disables Server Reflection lookups.
|
||||
/// 3. Uses the provided `FileDescriptorSet` for all future schema resolutions.
|
||||
///
|
||||
/// This is useful when the server does not support reflection or when you want to enforce
|
||||
/// a specific schema version.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
|
|
@ -111,30 +117,35 @@ where
|
|||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(GrancClient<WithFileDescriptor>)` - The new client state.
|
||||
/// * `Err(DescriptorError)` - If the provided bytes could not be decoded into a valid `DescriptorPool`.
|
||||
/// * `Ok(GrancClient<OnlineWithoutReflection>)` - The client in the new state.
|
||||
/// * `Err(DescriptorError)` - If the provided bytes cannot be decoded into a valid descriptor pool.
|
||||
pub fn with_file_descriptor(
|
||||
self,
|
||||
file_descriptor: Vec<u8>,
|
||||
) -> Result<GrancClient<WithFileDescriptor<S>>, DescriptorError> {
|
||||
) -> Result<GrancClient<OnlineWithoutReflection<S>>, DescriptorError> {
|
||||
let pool = DescriptorPool::decode(file_descriptor.as_slice())?;
|
||||
Ok(GrancClient::new(self.state.grpc_client, pool))
|
||||
|
||||
Ok(GrancClient::new(OnlineWithoutReflection::new(
|
||||
self.state.grpc_client,
|
||||
pool,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Lists all services exposed by the server by querying the reflection endpoint.
|
||||
/// Lists all services exposed by the server using the Reflection Protocol.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<String>)` - A list of fully qualified service names (e.g. `helloworld.Greeter`).
|
||||
/// * `Err(ReflectionResolveError)` - If the reflection call fails or the server doesn't support reflection.
|
||||
/// * `Ok(Vec<String>)` - A list of fully qualified service names (e.g., `helloworld.Greeter`).
|
||||
/// * `Err(ReflectionResolveError)` - If the reflection call fails, the stream is closed unexpectedly,
|
||||
/// or the server returns an error code.
|
||||
pub async fn list_services(&mut self) -> Result<Vec<String>, ReflectionResolveError> {
|
||||
self.state.reflection_client.list_services().await
|
||||
}
|
||||
|
||||
/// Resolves and fetches the descriptor for a specific symbol using reflection.
|
||||
/// Resolves and fetches the descriptor for a specific symbol using Reflection.
|
||||
///
|
||||
/// This will recursively fetch the file defining the symbol and all its dependencies
|
||||
/// from the server.
|
||||
/// This will query the server for the symbol, fetch the defining file, and recursively fetch
|
||||
/// all imported dependencies to build a complete `Descriptor`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
|
|
@ -143,7 +154,7 @@ where
|
|||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Descriptor)` - The resolved descriptor wrapper.
|
||||
/// * `Err(GetDescriptorError)` - If the symbol is not found or reflection fails.
|
||||
/// * `Err(GetDescriptorError)` - If the symbol is not found on the server or the reflection request fails.
|
||||
pub async fn get_descriptor_by_symbol(
|
||||
&mut self,
|
||||
symbol: &str,
|
||||
|
|
@ -163,29 +174,34 @@ where
|
|||
})?;
|
||||
|
||||
let pool = DescriptorPool::from_file_descriptor_set(fd_set)?;
|
||||
|
||||
let mut client =
|
||||
GrancClient::<WithFileDescriptor<S>>::new(self.state.grpc_client.clone(), pool);
|
||||
let client = GrancClient::new(Offline::new(pool));
|
||||
|
||||
client
|
||||
.get_descriptor_by_symbol(symbol)
|
||||
.ok_or_else(|| GetDescriptorError::NotFound(symbol.to_string()))
|
||||
}
|
||||
|
||||
/// Executes a dynamic gRPC request using reflection.
|
||||
///
|
||||
/// 1. It fetches the schema for the requested `service` via reflection.
|
||||
/// 2. It builds a temporary `WithFileDescriptor` client using that schema.
|
||||
/// 3. It delegates the call to that client.
|
||||
/// Executes a dynamic gRPC request using Server Reflection for schema resolution.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `request` - The [`DynamicRequest`] containing the method to call and the JSON body.
|
||||
/// * `request` - A [`DynamicRequest`] struct containing:
|
||||
/// - `service`: The fully qualified name of the service (e.g., `my.package.MyService`).
|
||||
/// - `method`: The name of the method to call (e.g., `MyMethod`).
|
||||
/// - `body`: The JSON payload (Object for Unary/ServerStreaming, Array for Client/BiDi Streaming).
|
||||
/// - `headers`: Optional gRPC metadata/headers.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(DynamicResponse)` - The result of the gRPC call (Unary or Streaming).
|
||||
/// * `Err(DynamicCallError)` - If schema resolution, validation, or the network call fails.
|
||||
/// * `Ok(DynamicResponse)` - The result of the call, which can be:
|
||||
/// - [`DynamicResponse::Unary`]: For Unary and Client Streaming calls (single response).
|
||||
/// - [`DynamicResponse::Streaming`]: For Server Streaming and Bidirectional calls (stream of responses).
|
||||
/// * `Err(DynamicCallError)` - If an error occurs during:
|
||||
/// - Reflection resolution (e.g., Service not found).
|
||||
/// - Schema parsing.
|
||||
/// - Request serialization (JSON to Proto).
|
||||
/// - Network transport.
|
||||
/// - Response deserialization.
|
||||
pub async fn dynamic(
|
||||
&mut self,
|
||||
request: DynamicRequest,
|
||||
|
|
@ -198,8 +214,10 @@ where
|
|||
|
||||
let pool = DescriptorPool::from_file_descriptor_set(fd_set)?;
|
||||
|
||||
let mut client =
|
||||
GrancClient::<WithFileDescriptor<S>>::new(self.state.grpc_client.clone(), pool);
|
||||
let mut client = GrancClient::new(OnlineWithoutReflection::new(
|
||||
self.state.grpc_client.clone(),
|
||||
pool,
|
||||
));
|
||||
|
||||
Ok(client.dynamic(request).await?)
|
||||
}
|
||||
116
granc-core/src/client/online_without_reflection.rs
Normal file
116
granc-core/src/client/online_without_reflection.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
//! # Client State: Online Without Reflection
|
||||
//!
|
||||
//! This module defines the `GrancClient` behavior when it is connected to a server
|
||||
//! but uses a local, in-memory `DescriptorPool` (Static schema) to resolve messages.
|
||||
use super::{DynamicRequest, DynamicResponse, GrancClient, OnlineWithoutReflection};
|
||||
use crate::{BoxError, client::OfflineReflectionState, grpc::client::GrpcRequestError};
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use http_body::Body as HttpBody;
|
||||
use std::fmt::Debug;
|
||||
|
||||
/// Errors that can occur during a dynamic call in OnlineWithoutReflection mode.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DynamicCallError {
|
||||
#[error("Invalid input: '{0}'")]
|
||||
InvalidInput(String),
|
||||
#[error("Service '{0}' not found")]
|
||||
ServiceNotFound(String),
|
||||
#[error("Method '{0}' not found")]
|
||||
MethodNotFound(String),
|
||||
#[error("gRPC client request error: '{0}'")]
|
||||
GrpcRequestError(#[from] GrpcRequestError),
|
||||
}
|
||||
|
||||
impl<S> GrancClient<OnlineWithoutReflection<S>>
|
||||
where
|
||||
S: tonic::client::GrpcService<tonic::body::Body> + Clone,
|
||||
S::ResponseBody: HttpBody<Data = tonic::codegen::Bytes> + Send + 'static,
|
||||
<S::ResponseBody as HttpBody>::Error: Into<BoxError> + Send,
|
||||
{
|
||||
/// Executes a dynamic gRPC request using the locally loaded `FileDescriptorSet`.
|
||||
///
|
||||
/// Unlike the `Online` state, this method does **not** make any calls to the server's reflection endpoint.
|
||||
/// It relies entirely on the local `DescriptorPool` provided during state transition (see [`super::Online::with_file_descriptor`]).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `request` - A [`DynamicRequest`] struct containing:
|
||||
/// - `service`: The fully qualified name of the service (e.g., `my.package.MyService`).
|
||||
/// - `method`: The name of the method to call (e.g., `MyMethod`).
|
||||
/// - `body`: The JSON payload.
|
||||
/// - `headers`: Optional gRPC metadata.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(DynamicResponse)` - The result of the call (Unary or Streaming).
|
||||
/// * `Err(DynamicCallError)` - If validation fails or the network call errors. Specific errors include:
|
||||
/// - [`DynamicCallError::ServiceNotFound`]: The service is not present in the local descriptor.
|
||||
/// - [`DynamicCallError::MethodNotFound`]: The method does not exist in the service.
|
||||
/// - [`DynamicCallError::InvalidInput`]: The JSON body structure is invalid for the streaming mode (e.g. object provided for streaming call).
|
||||
/// - [`DynamicCallError::GrpcRequestError`]: Transport-level errors (connection failed, timeout, etc).
|
||||
pub async fn dynamic(
|
||||
&mut self,
|
||||
request: DynamicRequest,
|
||||
) -> Result<DynamicResponse, DynamicCallError> {
|
||||
let method = self
|
||||
.state
|
||||
.descriptor_pool()
|
||||
.get_service_by_name(&request.service)
|
||||
.ok_or_else(|| DynamicCallError::ServiceNotFound(request.service.clone()))?
|
||||
.methods()
|
||||
.find(|m| m.name() == request.method)
|
||||
.ok_or_else(|| DynamicCallError::MethodNotFound(request.method.clone()))?;
|
||||
|
||||
match (method.is_client_streaming(), method.is_server_streaming()) {
|
||||
(false, false) => {
|
||||
let result = self
|
||||
.state
|
||||
.grpc_client
|
||||
.unary(method, request.body, request.headers)
|
||||
.await?;
|
||||
Ok(DynamicResponse::Unary(result))
|
||||
}
|
||||
(false, true) => match self
|
||||
.state
|
||||
.grpc_client
|
||||
.server_streaming(method, request.body, request.headers)
|
||||
.await?
|
||||
{
|
||||
Ok(stream) => Ok(DynamicResponse::Streaming(Ok(stream.collect().await))),
|
||||
Err(status) => Ok(DynamicResponse::Streaming(Err(status))),
|
||||
},
|
||||
(true, false) => {
|
||||
let input_stream =
|
||||
json_array_to_stream(request.body).map_err(DynamicCallError::InvalidInput)?;
|
||||
let result = self
|
||||
.state
|
||||
.grpc_client
|
||||
.client_streaming(method, input_stream, request.headers)
|
||||
.await?;
|
||||
Ok(DynamicResponse::Unary(result))
|
||||
}
|
||||
(true, true) => {
|
||||
let input_stream =
|
||||
json_array_to_stream(request.body).map_err(DynamicCallError::InvalidInput)?;
|
||||
match self
|
||||
.state
|
||||
.grpc_client
|
||||
.bidirectional_streaming(method, input_stream, request.headers)
|
||||
.await?
|
||||
{
|
||||
Ok(stream) => Ok(DynamicResponse::Streaming(Ok(stream.collect().await))),
|
||||
Err(status) => Ok(DynamicResponse::Streaming(Err(status))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_array_to_stream(
|
||||
json: serde_json::Value,
|
||||
) -> Result<impl Stream<Item = serde_json::Value> + Send + 'static, String> {
|
||||
match json {
|
||||
serde_json::Value::Array(items) => Ok(tokio_stream::iter(items)),
|
||||
_ => Err("Client streaming requires a JSON Array body".to_string()),
|
||||
}
|
||||
}
|
||||
63
granc-core/src/client/types.rs
Normal file
63
granc-core/src/client/types.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use prost_reflect::{EnumDescriptor, MessageDescriptor, ServiceDescriptor};
|
||||
use std::fmt::Debug;
|
||||
|
||||
/// A request object encapsulating all necessary information to perform a dynamic gRPC call.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DynamicRequest {
|
||||
/// The JSON body of the request.
|
||||
/// - For Unary/ServerStreaming: An Object `{}`.
|
||||
/// - For ClientStreaming/Bidirectional: An Array of Objects `[{}]`.
|
||||
pub body: serde_json::Value,
|
||||
/// Custom gRPC metadata (headers) to attach to the request.
|
||||
pub headers: Vec<(String, String)>,
|
||||
/// The fully qualified name of the service (e.g., `my.package.Service`).
|
||||
pub service: String,
|
||||
/// The name of the method to call (e.g., `SayHello`).
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
/// The result of a dynamic gRPC call.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DynamicResponse {
|
||||
/// A single response message (for Unary and Client Streaming calls).
|
||||
Unary(Result<serde_json::Value, tonic::Status>),
|
||||
/// A stream of response messages (for Server Streaming and Bidirectional calls).
|
||||
Streaming(Result<Vec<Result<serde_json::Value, tonic::Status>>, tonic::Status>),
|
||||
}
|
||||
|
||||
/// A generic wrapper for different types of Protobuf descriptors.
|
||||
///
|
||||
/// This enum allows the client to return a single type when resolving symbols,
|
||||
/// regardless of whether the symbol points to a Service, a Message, or an Enum.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Descriptor {
|
||||
MessageDescriptor(MessageDescriptor),
|
||||
ServiceDescriptor(ServiceDescriptor),
|
||||
EnumDescriptor(EnumDescriptor),
|
||||
}
|
||||
|
||||
impl Descriptor {
|
||||
/// Returns the inner [`MessageDescriptor`] if this variant is `MessageDescriptor`.
|
||||
pub fn message_descriptor(&self) -> Option<&MessageDescriptor> {
|
||||
match self {
|
||||
Descriptor::MessageDescriptor(d) => Some(d),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the inner [`ServiceDescriptor`] if this variant is `ServiceDescriptor`.
|
||||
pub fn service_descriptor(&self) -> Option<&ServiceDescriptor> {
|
||||
match self {
|
||||
Descriptor::ServiceDescriptor(d) => Some(d),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the inner [`EnumDescriptor`] if this variant is `EnumDescriptor`.
|
||||
pub fn enum_descriptor(&self) -> Option<&EnumDescriptor> {
|
||||
match self {
|
||||
Descriptor::EnumDescriptor(d) => Some(d),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
//! # Client State: File Descriptor
|
||||
//!
|
||||
//! This module defines the `GrancClient` behavior when it is using a local, in-memory
|
||||
//! `DescriptorPool` (loaded from a file) to resolve schemas.
|
||||
//!
|
||||
//! In this state, the client does **not** use server reflection for schema lookup.
|
||||
use super::WithFileDescriptor;
|
||||
use super::{Descriptor, DynamicRequest, DynamicResponse, GrancClient};
|
||||
use crate::{
|
||||
BoxError,
|
||||
grpc::client::{GrpcClient, GrpcRequestError},
|
||||
};
|
||||
use futures_util::Stream;
|
||||
use futures_util::StreamExt;
|
||||
use http_body::Body as HttpBody;
|
||||
use prost_reflect::DescriptorPool;
|
||||
use std::fmt::Debug;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DynamicCallError {
|
||||
#[error("Invalid input: '{0}'")]
|
||||
InvalidInput(String),
|
||||
|
||||
#[error("Service '{0}' not found")]
|
||||
ServiceNotFound(String),
|
||||
|
||||
#[error("Method '{0}' not found")]
|
||||
MethodNotFound(String),
|
||||
|
||||
#[error("gRPC client request error: '{0}'")]
|
||||
GrpcRequestError(#[from] GrpcRequestError),
|
||||
}
|
||||
|
||||
impl<S> GrancClient<WithFileDescriptor<S>>
|
||||
where
|
||||
S: Clone,
|
||||
{
|
||||
pub(crate) fn new(grpc_client: GrpcClient<S>, pool: DescriptorPool) -> Self {
|
||||
Self {
|
||||
state: WithFileDescriptor { grpc_client, pool },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> GrancClient<WithFileDescriptor<S>>
|
||||
where
|
||||
S: tonic::client::GrpcService<tonic::body::Body> + Clone,
|
||||
S::ResponseBody: HttpBody<Data = tonic::codegen::Bytes> + Send + 'static,
|
||||
<S::ResponseBody as HttpBody>::Error: Into<BoxError> + Send,
|
||||
{
|
||||
/// Lists all services defined in the loaded `DescriptorPool`.
|
||||
///
|
||||
/// Unlike the reflection client, this is a synchronous operation that returns
|
||||
/// immediately from memory.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A list of fully qualified service names (e.g. `helloworld.Greeter`).
|
||||
pub fn list_services(&mut self) -> Vec<String> {
|
||||
self.state
|
||||
.pool
|
||||
.services()
|
||||
.map(|s| s.full_name().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Looks up a specific symbol in the loaded `DescriptorPool`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `symbol` - The fully qualified name of the symbol (Service, Message, or Enum).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Some(Descriptor)` - The resolved descriptor if found.
|
||||
/// * `None` - If the symbol does not exist in the pool.
|
||||
pub fn get_descriptor_by_symbol(&mut self, symbol: &str) -> Option<Descriptor> {
|
||||
let pool = &self.state.pool;
|
||||
|
||||
if let Some(descriptor) = pool.get_service_by_name(symbol) {
|
||||
return Some(Descriptor::ServiceDescriptor(descriptor));
|
||||
}
|
||||
|
||||
if let Some(descriptor) = pool.get_message_by_name(symbol) {
|
||||
return Some(Descriptor::MessageDescriptor(descriptor));
|
||||
}
|
||||
|
||||
if let Some(descriptor) = pool.get_enum_by_name(symbol) {
|
||||
return Some(Descriptor::EnumDescriptor(descriptor));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Executes a dynamic gRPC request using the loaded `DescriptorPool`.
|
||||
///
|
||||
/// It looks up the service and method definitions in the local pool, validates the JSON, and sends the request.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `request` - The [`DynamicRequest`] containing the method to call and the JSON body.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(DynamicResponse)` - The result of the gRPC call.
|
||||
/// * `Err(DynamicCallError)` - If the service/method is not in the pool, the JSON is invalid, or the call fails.
|
||||
pub async fn dynamic(
|
||||
&mut self,
|
||||
request: DynamicRequest,
|
||||
) -> Result<DynamicResponse, DynamicCallError> {
|
||||
let method = self
|
||||
.state
|
||||
.pool
|
||||
.get_service_by_name(&request.service)
|
||||
.ok_or_else(|| DynamicCallError::ServiceNotFound(request.service))?
|
||||
.methods()
|
||||
.find(|m| m.name() == request.method)
|
||||
.ok_or_else(|| DynamicCallError::MethodNotFound(request.method))?;
|
||||
|
||||
match (method.is_client_streaming(), method.is_server_streaming()) {
|
||||
(false, false) => {
|
||||
let result = self
|
||||
.state
|
||||
.grpc_client
|
||||
.unary(method, request.body, request.headers)
|
||||
.await?;
|
||||
Ok(DynamicResponse::Unary(result))
|
||||
}
|
||||
|
||||
(false, true) => {
|
||||
match self
|
||||
.state
|
||||
.grpc_client
|
||||
.server_streaming(method, request.body, request.headers)
|
||||
.await?
|
||||
{
|
||||
Ok(stream) => Ok(DynamicResponse::Streaming(Ok(stream.collect().await))),
|
||||
Err(status) => Ok(DynamicResponse::Streaming(Err(status))),
|
||||
}
|
||||
}
|
||||
(true, false) => {
|
||||
let input_stream =
|
||||
json_array_to_stream(request.body).map_err(DynamicCallError::InvalidInput)?;
|
||||
let result = self
|
||||
.state
|
||||
.grpc_client
|
||||
.client_streaming(method, input_stream, request.headers)
|
||||
.await?;
|
||||
Ok(DynamicResponse::Unary(result))
|
||||
}
|
||||
|
||||
(true, true) => {
|
||||
let input_stream =
|
||||
json_array_to_stream(request.body).map_err(DynamicCallError::InvalidInput)?;
|
||||
match self
|
||||
.state
|
||||
.grpc_client
|
||||
.bidirectional_streaming(method, input_stream, request.headers)
|
||||
.await?
|
||||
{
|
||||
Ok(stream) => Ok(DynamicResponse::Streaming(Ok(stream.collect().await))),
|
||||
Err(status) => Ok(DynamicResponse::Streaming(Err(status))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to convert a JSON Array into a Stream of JSON Values.
|
||||
/// Required for Client and Bidirectional streaming.
|
||||
fn json_array_to_stream(
|
||||
json: serde_json::Value,
|
||||
) -> Result<impl Stream<Item = serde_json::Value> + Send + 'static, String> {
|
||||
match json {
|
||||
serde_json::Value::Array(items) => Ok(tokio_stream::iter(items)),
|
||||
_ => Err("Client streaming requires a JSON Array body".to_string()),
|
||||
}
|
||||
}
|
||||
49
granc-core/tests/granc_client_offline_test.rs
Normal file
49
granc-core/tests/granc_client_offline_test.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
use echo_service::FILE_DESCRIPTOR_SET;
|
||||
use granc_core::client::{Descriptor, GrancClient};
|
||||
|
||||
#[test]
|
||||
fn test_offline_list_services() {
|
||||
let client = GrancClient::offline(FILE_DESCRIPTOR_SET.to_vec())
|
||||
.expect("Failed to load file descriptor set");
|
||||
|
||||
let mut services = client.list_services();
|
||||
services.sort();
|
||||
|
||||
assert_eq!(services.as_slice(), ["echo.EchoService"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offline_describe_descriptors() {
|
||||
let client = GrancClient::offline(FILE_DESCRIPTOR_SET.to_vec())
|
||||
.expect("Failed to load file descriptor set");
|
||||
|
||||
// 1. Success: Service
|
||||
let desc = client
|
||||
.get_descriptor_by_symbol("echo.EchoService")
|
||||
.expect("Service not found");
|
||||
|
||||
assert!(matches!(
|
||||
desc,
|
||||
Descriptor::ServiceDescriptor(s) if s.name() == "EchoService"
|
||||
));
|
||||
|
||||
// 2. Success: Message
|
||||
let desc = client
|
||||
.get_descriptor_by_symbol("echo.EchoRequest")
|
||||
.expect("Message not found");
|
||||
|
||||
assert!(matches!(
|
||||
desc,
|
||||
Descriptor::MessageDescriptor(m) if m.name() == "EchoRequest"
|
||||
));
|
||||
|
||||
// 3. Error: Symbol Not Found
|
||||
let desc = client.get_descriptor_by_symbol("echo.Ghost");
|
||||
assert!(desc.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offline_creation_error() {
|
||||
let result = GrancClient::offline(vec![0, 1, 2, 3]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
|
@ -1,16 +1,14 @@
|
|||
use echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET};
|
||||
use echo_service_impl::EchoServiceImpl;
|
||||
use granc_core::client::{
|
||||
Descriptor, DynamicRequest, DynamicResponse, GrancClient, WithServerReflection,
|
||||
with_file_descriptor, with_server_reflection,
|
||||
};
|
||||
use granc_core::client::{DynamicRequest, DynamicResponse, GrancClient, Online, online};
|
||||
use granc_core::reflection::client::ReflectionResolveError;
|
||||
use tonic::Code;
|
||||
use tonic::service::Routes;
|
||||
|
||||
mod echo_service_impl;
|
||||
|
||||
async fn setup_client() -> GrancClient<WithServerReflection<Routes>> {
|
||||
async fn setup_client() -> GrancClient<Online<Routes>> {
|
||||
// Enable Reflection
|
||||
let reflection_service = tonic_reflection::server::Builder::configure()
|
||||
.register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
|
||||
.build_v1()
|
||||
|
|
@ -20,15 +18,13 @@ async fn setup_client() -> GrancClient<WithServerReflection<Routes>> {
|
|||
|
||||
let service = Routes::new(reflection_service).add_service(echo_service);
|
||||
|
||||
GrancClient::from_service(service)
|
||||
GrancClient::from(service)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_list_services() {
|
||||
let mut client = setup_client().await;
|
||||
|
||||
let mut services = client.list_services().await.unwrap();
|
||||
|
||||
services.sort();
|
||||
|
||||
assert_eq!(
|
||||
|
|
@ -38,63 +34,24 @@ async fn test_reflection_list_services() {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_describe_descriptors() {
|
||||
async fn test_reflection_unary_success() {
|
||||
let mut client = setup_client().await;
|
||||
|
||||
let desc = client
|
||||
.get_descriptor_by_symbol("echo.EchoService")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
desc,
|
||||
Descriptor::ServiceDescriptor(s)
|
||||
if s.name() == "EchoService"
|
||||
&& s.methods().any(|m| m.name() == "UnaryEcho")
|
||||
));
|
||||
|
||||
let desc = client
|
||||
.get_descriptor_by_symbol("echo.EchoRequest")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
desc,
|
||||
Descriptor::MessageDescriptor(m)
|
||||
if m.name() == "EchoRequest"
|
||||
&& m.fields().any(|f| f.name() == "message")
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_describe_error() {
|
||||
let mut client = setup_client().await;
|
||||
|
||||
let result = client.get_descriptor_by_symbol("echo.Ghost").await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(with_server_reflection::GetDescriptorError::NotFound(name)) if name == "echo.Ghost"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_dynamic_calls() {
|
||||
let mut client = setup_client().await;
|
||||
|
||||
// Unary Call
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "UnaryEcho".to_string(),
|
||||
body: serde_json::json!({ "message": "hello" }),
|
||||
body: serde_json::json!({ "message": "reflection" }),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let res = client.dynamic(req).await.unwrap();
|
||||
assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "reflection"));
|
||||
}
|
||||
|
||||
assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "hello"));
|
||||
#[tokio::test]
|
||||
async fn test_reflection_server_streaming_success() {
|
||||
let mut client = setup_client().await;
|
||||
|
||||
// Server Streaming
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "ServerStreamingEcho".to_string(),
|
||||
|
|
@ -104,46 +61,38 @@ async fn test_reflection_dynamic_calls() {
|
|||
|
||||
let res = client.dynamic(req).await.unwrap();
|
||||
|
||||
assert!(matches!(res, DynamicResponse::Streaming(Ok(stream)) if stream.len() == 3));
|
||||
|
||||
// Client Streaming
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "ClientStreamingEcho".to_string(),
|
||||
body: serde_json::json!([
|
||||
{ "message": "A" },
|
||||
{ "message": "B" }
|
||||
]),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let res = client.dynamic(req).await.unwrap();
|
||||
|
||||
assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "AB"));
|
||||
|
||||
// Bidirectional Streaming
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "BidirectionalEcho".to_string(),
|
||||
body: serde_json::json!([
|
||||
{ "message": "Ping" }
|
||||
]),
|
||||
headers: vec![],
|
||||
};
|
||||
let res = client.dynamic(req).await.unwrap();
|
||||
|
||||
assert!(matches!(res,
|
||||
DynamicResponse::Streaming(Ok(stream))
|
||||
if stream.len() == 1
|
||||
&& stream[0].as_ref().unwrap()["message"] == "echo: Ping"
|
||||
));
|
||||
match res {
|
||||
DynamicResponse::Streaming(Ok(stream)) => {
|
||||
assert_eq!(stream.len(), 3);
|
||||
assert_eq!(stream[0].as_ref().unwrap()["message"], "stream - seq 0");
|
||||
assert_eq!(stream[1].as_ref().unwrap()["message"], "stream - seq 1");
|
||||
assert_eq!(stream[2].as_ref().unwrap()["message"], "stream - seq 2");
|
||||
}
|
||||
_ => panic!("Expected Streaming response"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_dynamic_error_cases() {
|
||||
async fn test_reflection_client_streaming_success() {
|
||||
let mut client = setup_client().await;
|
||||
|
||||
// Invalid Service Name
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "ClientStreamingEcho".to_string(),
|
||||
body: serde_json::json!([{ "message": "A" }, { "message": "B" }]),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let res = client.dynamic(req).await.unwrap();
|
||||
assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "AB"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_service_not_found() {
|
||||
let mut client = setup_client().await;
|
||||
|
||||
// Requesting a service that doesn't exist on the server.
|
||||
// This fails during the Reflection Lookup phase.
|
||||
let req = DynamicRequest {
|
||||
service: "echo.GhostService".to_string(),
|
||||
method: "UnaryEcho".to_string(),
|
||||
|
|
@ -155,12 +104,18 @@ async fn test_reflection_dynamic_error_cases() {
|
|||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(with_server_reflection::DynamicCallError::ReflectionResolve(
|
||||
Err(online::DynamicCallError::ReflectionResolve(
|
||||
ReflectionResolveError::ServerStreamFailure(status)
|
||||
)) if status.code() == Code::NotFound
|
||||
));
|
||||
}
|
||||
|
||||
// Invalid Method Name
|
||||
#[tokio::test]
|
||||
async fn test_reflection_method_not_found() {
|
||||
let mut client = setup_client().await;
|
||||
|
||||
// The service exists, so reflection succeeds in fetching the schema.
|
||||
// However, the schema does not contain "GhostMethod", so it fails locally before call.
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "GhostMethod".to_string(),
|
||||
|
|
@ -172,17 +127,21 @@ async fn test_reflection_dynamic_error_cases() {
|
|||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(with_server_reflection::DynamicCallError::DynamicCallError(
|
||||
with_file_descriptor::DynamicCallError::MethodNotFound(name)
|
||||
Err(online::DynamicCallError::DynamicCallError(
|
||||
granc_core::client::online_without_reflection::DynamicCallError::MethodNotFound(name)
|
||||
)) if name == "GhostMethod"
|
||||
));
|
||||
}
|
||||
|
||||
// Invalid JSON Structure (Streaming requires Array, Object provided)
|
||||
// This triggers `DynamicCallError::InvalidInput` before the request is sent.
|
||||
#[tokio::test]
|
||||
async fn test_reflection_invalid_input_structure() {
|
||||
let mut client = setup_client().await;
|
||||
|
||||
// Client streaming requires Array.
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "ClientStreamingEcho".to_string(),
|
||||
body: serde_json::json!({ "message": "I should be an array" }),
|
||||
body: serde_json::json!({ "msg": "not array" }),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
|
|
@ -190,18 +149,22 @@ async fn test_reflection_dynamic_error_cases() {
|
|||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(with_server_reflection::DynamicCallError::DynamicCallError(
|
||||
with_file_descriptor::DynamicCallError::InvalidInput(_)
|
||||
Err(online::DynamicCallError::DynamicCallError(
|
||||
granc_core::client::online_without_reflection::DynamicCallError::InvalidInput(_)
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
// Schema Mismatch (Unary)
|
||||
// Passing a field that doesn't exist. This fails at encoding time inside the Codec.
|
||||
// Tonic wraps encoding errors as Code::Internal.
|
||||
#[tokio::test]
|
||||
async fn test_reflection_schema_mismatch() {
|
||||
let mut client = setup_client().await;
|
||||
|
||||
// Field "wrong_field" does not exist in the protobuf definition.
|
||||
// Should fail with InvalidArgument during encoding.
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "UnaryEcho".to_string(),
|
||||
body: serde_json::json!({ "non_existent_field": "oops" }),
|
||||
body: serde_json::json!({ "wrong_field": "val" }),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
use echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET};
|
||||
use echo_service_impl::EchoServiceImpl;
|
||||
use granc_core::client::{
|
||||
Descriptor, DynamicRequest, DynamicResponse, GrancClient, WithFileDescriptor,
|
||||
with_file_descriptor,
|
||||
DynamicRequest, DynamicResponse, GrancClient, OnlineWithoutReflection,
|
||||
online_without_reflection,
|
||||
};
|
||||
use tonic::Code;
|
||||
|
||||
mod echo_service_impl;
|
||||
|
||||
fn setup_client() -> GrancClient<WithFileDescriptor<EchoServiceServer<EchoServiceImpl>>> {
|
||||
fn setup_client() -> GrancClient<OnlineWithoutReflection<EchoServiceServer<EchoServiceImpl>>> {
|
||||
let service = EchoServiceServer::new(EchoServiceImpl);
|
||||
let client_reflection = GrancClient::from_service(service);
|
||||
let client_reflection = GrancClient::from(service);
|
||||
|
||||
client_reflection
|
||||
.with_file_descriptor(FILE_DESCRIPTOR_SET.to_vec())
|
||||
|
|
@ -18,49 +18,9 @@ fn setup_client() -> GrancClient<WithFileDescriptor<EchoServiceServer<EchoServic
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_services() {
|
||||
async fn test_dynamic_unary_success() {
|
||||
let mut client = setup_client();
|
||||
|
||||
let services = client.list_services();
|
||||
|
||||
assert_eq!(services.as_slice(), ["echo.EchoService"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_describe_descriptors() {
|
||||
let mut client = setup_client();
|
||||
|
||||
// Describe Service
|
||||
let desc = client
|
||||
.get_descriptor_by_symbol("echo.EchoService")
|
||||
.expect("Service not found");
|
||||
|
||||
assert!(matches!(
|
||||
desc,
|
||||
Descriptor::ServiceDescriptor(s) if s.name() == "EchoService"
|
||||
));
|
||||
|
||||
// Describe Message
|
||||
let desc = client
|
||||
.get_descriptor_by_symbol("echo.EchoRequest")
|
||||
.expect("Message not found");
|
||||
|
||||
assert!(matches!(
|
||||
desc,
|
||||
Descriptor::MessageDescriptor(m) if m.name() == "EchoRequest"
|
||||
));
|
||||
|
||||
// Error Case: Returns None
|
||||
let desc = client.get_descriptor_by_symbol("echo.Ghost");
|
||||
|
||||
assert!(desc.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dynamic_calls() {
|
||||
let mut client = setup_client();
|
||||
|
||||
// Unary Call
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "UnaryEcho".to_string(),
|
||||
|
|
@ -70,9 +30,16 @@ async fn test_dynamic_calls() {
|
|||
|
||||
let res = client.dynamic(req).await.unwrap();
|
||||
|
||||
assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "hello"));
|
||||
assert!(matches!(
|
||||
res,
|
||||
DynamicResponse::Unary(Ok(val)) if val["message"] == "hello"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dynamic_server_streaming_success() {
|
||||
let mut client = setup_client();
|
||||
|
||||
// Server Streaming
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "ServerStreamingEcho".to_string(),
|
||||
|
|
@ -82,46 +49,71 @@ async fn test_dynamic_calls() {
|
|||
|
||||
let res = client.dynamic(req).await.unwrap();
|
||||
|
||||
assert!(matches!(res, DynamicResponse::Streaming(Ok(stream)) if stream.len() == 3));
|
||||
match res {
|
||||
DynamicResponse::Streaming(Ok(stream)) => {
|
||||
assert_eq!(stream.len(), 3);
|
||||
assert_eq!(stream[0].as_ref().unwrap()["message"], "stream - seq 0");
|
||||
assert_eq!(stream[1].as_ref().unwrap()["message"], "stream - seq 1");
|
||||
assert_eq!(stream[2].as_ref().unwrap()["message"], "stream - seq 2");
|
||||
}
|
||||
_ => panic!("Expected Streaming response"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dynamic_client_streaming_success() {
|
||||
let mut client = setup_client();
|
||||
|
||||
// Client Streaming
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "ClientStreamingEcho".to_string(),
|
||||
// Client streaming requires a JSON Array
|
||||
body: serde_json::json!([
|
||||
{ "message": "A" },
|
||||
{ "message": "B" }
|
||||
{ "message": "B" },
|
||||
{ "message": "C" }
|
||||
]),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let res = client.dynamic(req).await.unwrap();
|
||||
|
||||
assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "AB"));
|
||||
|
||||
// Bidirectional Streaming
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "BidirectionalEcho".to_string(),
|
||||
body: serde_json::json!([
|
||||
{ "message": "Ping" }
|
||||
]),
|
||||
headers: vec![],
|
||||
};
|
||||
let res = client.dynamic(req).await.unwrap();
|
||||
|
||||
assert!(matches!(res,
|
||||
DynamicResponse::Streaming(Ok(stream))
|
||||
if stream.len() == 1
|
||||
&& stream[0].as_ref().unwrap()["message"] == "echo: Ping"
|
||||
assert!(matches!(
|
||||
res,
|
||||
DynamicResponse::Unary(Ok(val)) if val["message"] == "ABC"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_cases() {
|
||||
async fn test_dynamic_bidirectional_streaming_success() {
|
||||
let mut client = setup_client();
|
||||
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "BidirectionalEcho".to_string(),
|
||||
body: serde_json::json!([
|
||||
{ "message": "Ping" },
|
||||
{ "message": "Pong" }
|
||||
]),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let res = client.dynamic(req).await.unwrap();
|
||||
|
||||
match res {
|
||||
DynamicResponse::Streaming(Ok(stream)) => {
|
||||
assert_eq!(stream.len(), 2);
|
||||
assert_eq!(stream[0].as_ref().unwrap()["message"], "echo: Ping");
|
||||
assert_eq!(stream[1].as_ref().unwrap()["message"], "echo: Pong");
|
||||
}
|
||||
_ => panic!("Expected Streaming response"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_service_not_found() {
|
||||
let mut client = setup_client();
|
||||
|
||||
// Service Not Found
|
||||
let req = DynamicRequest {
|
||||
service: "echo.GhostService".to_string(),
|
||||
method: "UnaryEcho".to_string(),
|
||||
|
|
@ -133,10 +125,14 @@ async fn test_error_cases() {
|
|||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(with_file_descriptor::DynamicCallError::ServiceNotFound(name)) if name == "echo.GhostService"
|
||||
Err(online_without_reflection::DynamicCallError::ServiceNotFound(name)) if name == "echo.GhostService"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_method_not_found() {
|
||||
let mut client = setup_client();
|
||||
|
||||
// Method Not Found
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "GhostMethod".to_string(),
|
||||
|
|
@ -148,10 +144,15 @@ async fn test_error_cases() {
|
|||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(with_file_descriptor::DynamicCallError::MethodNotFound(name)) if name == "GhostMethod"
|
||||
Err(online_without_reflection::DynamicCallError::MethodNotFound(name)) if name == "GhostMethod"
|
||||
));
|
||||
}
|
||||
|
||||
// Invalid JSON Structure (Streaming requires Array)
|
||||
#[tokio::test]
|
||||
async fn test_error_invalid_input_structure() {
|
||||
let mut client = setup_client();
|
||||
|
||||
// Client streaming requires an Array, passing an Object should fail
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "ClientStreamingEcho".to_string(),
|
||||
|
|
@ -163,11 +164,16 @@ async fn test_error_cases() {
|
|||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(with_file_descriptor::DynamicCallError::InvalidInput(_))
|
||||
Err(online_without_reflection::DynamicCallError::InvalidInput(_))
|
||||
));
|
||||
}
|
||||
|
||||
// Schema Mismatch (Unary)
|
||||
// Field mismatch causes encoding error -> Status::InvalidArgument
|
||||
#[tokio::test]
|
||||
async fn test_error_schema_mismatch() {
|
||||
let mut client = setup_client();
|
||||
|
||||
// Passing a field ("unknown_field") that doesn't exist in the EchoRequest proto definition.
|
||||
// The JsonCodec (in granc-core/src/grpc/codec.rs) maps this to Status::InvalidArgument.
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "UnaryEcho".to_string(),
|
||||
|
|
@ -177,10 +183,12 @@ async fn test_error_cases() {
|
|||
|
||||
let result = client.dynamic(req).await;
|
||||
|
||||
// This error happens during encoding inside the Tonic stack, so it returns
|
||||
// a successful Result<DynamicResponse> containing an Err(Status).
|
||||
assert!(matches!(
|
||||
result,
|
||||
Ok(DynamicResponse::Unary(Err(status)))
|
||||
if status.code() == Code::Internal
|
||||
&& status.message().contains("JSON structure does not match")
|
||||
&& status.message().contains("JSON structure does not match Protobuf schema")
|
||||
));
|
||||
}
|
||||
|
|
@ -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"] }
|
||||
|
|
|
|||
302
granc/src/cli.rs
302
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<PathBuf>,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Commands {
|
||||
/// Perform a gRPC call to a server
|
||||
/// Perform a gRPC call to a server.
|
||||
///
|
||||
/// This command connects to a gRPC server and executes a method using a JSON body.
|
||||
/// Requires a server URI. Can optionally use a local file descriptor set.
|
||||
Call {
|
||||
/// Endpoint (package.Service/Method)
|
||||
#[arg(value_parser = parse_endpoint)]
|
||||
endpoint: (String, String),
|
||||
|
||||
/// The server URI to connect to (e.g. http://localhost:50051)
|
||||
#[arg(long, short = 'u')]
|
||||
uri: String,
|
||||
|
||||
/// "JSON body (Object for Unary, Array for Streaming)"
|
||||
#[arg(long, value_parser = parse_body)]
|
||||
#[arg(long, short = 'b', value_parser = parse_body)]
|
||||
body: serde_json::Value,
|
||||
|
||||
#[arg(short = 'H', long = "header", value_parser = parse_header)]
|
||||
headers: Vec<(String, String)>,
|
||||
|
||||
/// Optional path to a file descriptor set (.bin) to use instead of reflection
|
||||
#[arg(long, short = 'f')]
|
||||
file_descriptor_set: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// List available services
|
||||
List,
|
||||
/// List available services.
|
||||
///
|
||||
/// Requires EITHER a server URI (Reflection) OR a file descriptor set (Offline).
|
||||
List {
|
||||
#[command(flatten)]
|
||||
source: SourceSelection,
|
||||
},
|
||||
|
||||
/// Describe a service, message or enum passing the full path
|
||||
/// Describe a service, message or enum.
|
||||
///
|
||||
/// Requires EITHER a server URI (Reflection) OR a file descriptor set (Offline).
|
||||
Describe {
|
||||
#[command(flatten)]
|
||||
source: SourceSelection,
|
||||
|
||||
/// Fully qualified name (e.g. my.package.Service)
|
||||
symbol: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
#[group(required = true, multiple = false)] // Enforces: Either URI OR FileDescriptorSet, never both.
|
||||
pub struct SourceSelection {
|
||||
/// The server URI to use for reflection-based introspection
|
||||
#[arg(long, short = 'u')]
|
||||
uri: Option<String>,
|
||||
|
||||
/// Path to the descriptor set (.bin) to use for offline introspection
|
||||
#[arg(long, short = 'f')]
|
||||
file_descriptor_set: Option<PathBuf>,
|
||||
}
|
||||
|
||||
// The source where to resolve the proto schemas from.
|
||||
//
|
||||
// It can either be a URI (If the server supports server streaming)
|
||||
// or a file (a `.bin` or `.pb` file generated with protoc)
|
||||
pub enum Source {
|
||||
Uri(String),
|
||||
File(PathBuf),
|
||||
}
|
||||
|
||||
impl SourceSelection {
|
||||
pub fn value(self) -> Source {
|
||||
if let Some(uri) = self.uri {
|
||||
Source::Uri(uri)
|
||||
} else if let Some(path) = self.file_descriptor_set {
|
||||
Source::File(path)
|
||||
} else {
|
||||
// This is unreachable because `clap` verifies the group requirements before we ever get here.
|
||||
unreachable!(
|
||||
"Clap ensures exactly one argument (uri or file) is present via #[group(required = true)]"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_endpoint(value: &str) -> Result<(String, String), String> {
|
||||
let (service, method) = value.split_once('/').ok_or_else(|| {
|
||||
format!("Invalid endpoint format: '{value}'. Expected 'package.Service/Method'",)
|
||||
|
|
@ -69,3 +116,222 @@ fn parse_header(s: &str) -> Result<(String, String), String> {
|
|||
fn parse_body(value: &str) -> Result<serde_json::Value, String> {
|
||||
serde_json::from_str(value).map_err(|e| format!("Invalid JSON: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn test_call_command_reflection() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"call",
|
||||
"helloworld.Greeter/SayHello",
|
||||
"--uri",
|
||||
"http://localhost:50051",
|
||||
"--body",
|
||||
r#"{"name": "Ferris"}"#,
|
||||
];
|
||||
|
||||
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
|
||||
|
||||
match cli.command {
|
||||
Commands::Call {
|
||||
endpoint,
|
||||
uri,
|
||||
body,
|
||||
file_descriptor_set,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(
|
||||
endpoint,
|
||||
("helloworld.Greeter".to_string(), "SayHello".to_string())
|
||||
);
|
||||
assert_eq!(uri, "http://localhost:50051");
|
||||
assert_eq!(body, serde_json::json!({"name": "Ferris"}));
|
||||
assert!(file_descriptor_set.is_none());
|
||||
}
|
||||
_ => panic!("Expected Call command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_call_command_with_file_descriptor() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"call",
|
||||
"helloworld.Greeter/SayHello",
|
||||
"--uri",
|
||||
"http://localhost:50051",
|
||||
"--body",
|
||||
r#"{"name": "Ferris"}"#,
|
||||
"--file-descriptor-set",
|
||||
"./descriptors.bin",
|
||||
];
|
||||
|
||||
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
|
||||
|
||||
match cli.command {
|
||||
Commands::Call {
|
||||
file_descriptor_set,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(
|
||||
file_descriptor_set.unwrap().to_str().unwrap(),
|
||||
"./descriptors.bin"
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected Call command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_call_command_short_flags() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"call",
|
||||
"svc/mthd",
|
||||
"-u",
|
||||
"http://localhost:50051",
|
||||
"-b",
|
||||
"{}",
|
||||
"-f",
|
||||
"desc.bin",
|
||||
"-H",
|
||||
"auth:bearer",
|
||||
];
|
||||
|
||||
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
|
||||
|
||||
match cli.command {
|
||||
Commands::Call {
|
||||
uri,
|
||||
file_descriptor_set,
|
||||
headers,
|
||||
body,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(uri, "http://localhost:50051");
|
||||
assert_eq!(file_descriptor_set.unwrap().to_str().unwrap(), "desc.bin");
|
||||
assert_eq!(body, serde_json::json!({}));
|
||||
assert_eq!(headers[0], ("auth".to_string(), "bearer".to_string()));
|
||||
}
|
||||
_ => panic!("Expected Call command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_command_reflection() {
|
||||
let args = vec!["granc", "list", "--uri", "http://localhost:50051"];
|
||||
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
|
||||
|
||||
match cli.command {
|
||||
Commands::List { source } => {
|
||||
assert_eq!(source.uri.unwrap(), "http://localhost:50051");
|
||||
assert!(source.file_descriptor_set.is_none());
|
||||
}
|
||||
_ => panic!("Expected List command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_command_offline() {
|
||||
let args = vec!["granc", "list", "--file-descriptor-set", "desc.bin"];
|
||||
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
|
||||
|
||||
match cli.command {
|
||||
Commands::List { source } => {
|
||||
assert_eq!(
|
||||
source.file_descriptor_set.unwrap().to_str().unwrap(),
|
||||
"desc.bin"
|
||||
);
|
||||
assert!(source.uri.is_none());
|
||||
}
|
||||
_ => panic!("Expected List command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_describe_command() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"describe",
|
||||
"helloworld.Greeter",
|
||||
"--uri",
|
||||
"http://localhost:50051",
|
||||
];
|
||||
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
|
||||
|
||||
match cli.command {
|
||||
Commands::Describe { symbol, source } => {
|
||||
assert_eq!(symbol, "helloworld.Greeter");
|
||||
assert!(source.uri.is_some());
|
||||
}
|
||||
_ => panic!("Expected Describe command"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Failure Cases ---
|
||||
|
||||
#[test]
|
||||
fn test_fail_invalid_json_body() {
|
||||
let args = vec!["granc", "call", "s/m", "-u", "x", "--body", "{invalid_json"];
|
||||
let err = Cli::try_parse_from(&args).unwrap_err();
|
||||
// Should verify that the error comes from the body parser
|
||||
assert!(err.to_string().contains("Invalid JSON"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fail_invalid_endpoint_format() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"call",
|
||||
"OnlyServiceNoMethod", // Missing '/'
|
||||
"-u",
|
||||
"x",
|
||||
"-b",
|
||||
"{}",
|
||||
];
|
||||
let err = Cli::try_parse_from(&args).unwrap_err();
|
||||
assert!(err.to_string().contains("Invalid endpoint format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fail_list_requires_source() {
|
||||
let args = vec!["granc", "list"];
|
||||
let err = Cli::try_parse_from(&args).unwrap_err();
|
||||
// Clap error for missing required arguments in group
|
||||
assert!(err.kind() == clap::error::ErrorKind::MissingRequiredArgument);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fail_list_mutual_exclusion() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"list",
|
||||
"--uri",
|
||||
"http://host",
|
||||
"--file-descriptor-set",
|
||||
"file.bin",
|
||||
];
|
||||
let err = Cli::try_parse_from(&args).unwrap_err();
|
||||
// Clap error for argument conflict
|
||||
assert!(err.kind() == clap::error::ErrorKind::ArgumentConflict);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fail_describe_mutual_exclusion() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"describe",
|
||||
"Symbol",
|
||||
"-u",
|
||||
"http://host",
|
||||
"-f",
|
||||
"file.bin",
|
||||
];
|
||||
let err = Cli::try_parse_from(&args).unwrap_err();
|
||||
assert!(err.kind() == clap::error::ErrorKind::ArgumentConflict);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use colored::*;
|
||||
use granc_core::{
|
||||
client::{with_file_descriptor, with_server_reflection},
|
||||
client::{Descriptor, DynamicResponse, online, online_without_reflection},
|
||||
prost_reflect::{
|
||||
self, EnumDescriptor, Kind, MessageDescriptor, MethodDescriptor, ServiceDescriptor,
|
||||
},
|
||||
|
|
@ -42,16 +42,36 @@ impl From<Status> for FormattedString {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<DynamicResponse> for FormattedString {
|
||||
fn from(value: DynamicResponse) -> Self {
|
||||
match value {
|
||||
DynamicResponse::Unary(Ok(value)) => FormattedString::from(value),
|
||||
DynamicResponse::Unary(Err(status)) => FormattedString::from(status),
|
||||
DynamicResponse::Streaming(Ok(values)) => {
|
||||
let mut s = String::new();
|
||||
for elem in values {
|
||||
match elem {
|
||||
Ok(val) => s.push_str(&FormattedString::from(val).0),
|
||||
Err(status) => s.push_str(&FormattedString::from(status).0),
|
||||
}
|
||||
}
|
||||
FormattedString(s)
|
||||
}
|
||||
DynamicResponse::Streaming(Err(status)) => FormattedString::from(status),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error from Reflection-based calls
|
||||
impl From<with_server_reflection::DynamicCallError> for FormattedString {
|
||||
fn from(err: with_server_reflection::DynamicCallError) -> Self {
|
||||
impl From<online::DynamicCallError> for FormattedString {
|
||||
fn from(err: online::DynamicCallError) -> Self {
|
||||
FormattedString(format!("{}\n\n'{}'", "Call Failed:".red().bold(), err))
|
||||
}
|
||||
}
|
||||
|
||||
// Error from FileDescriptor-based calls
|
||||
impl From<with_file_descriptor::DynamicCallError> for FormattedString {
|
||||
fn from(err: with_file_descriptor::DynamicCallError) -> Self {
|
||||
impl From<online_without_reflection::DynamicCallError> for FormattedString {
|
||||
fn from(err: online_without_reflection::DynamicCallError) -> Self {
|
||||
FormattedString(format!("{}\n\n'{}'", "Call Failed:".red().bold(), err))
|
||||
}
|
||||
}
|
||||
|
|
@ -82,14 +102,14 @@ impl<T: Display> From<GenericError<T>> for FormattedString {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<with_server_reflection::ClientConnectError> for FormattedString {
|
||||
fn from(err: with_server_reflection::ClientConnectError) -> Self {
|
||||
impl From<online::ClientConnectError> for FormattedString {
|
||||
fn from(err: online::ClientConnectError) -> Self {
|
||||
FormattedString(format!("{}\n\n'{}'", "Connection Error:".red().bold(), err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<with_server_reflection::GetDescriptorError> for FormattedString {
|
||||
fn from(err: with_server_reflection::GetDescriptorError) -> Self {
|
||||
impl From<online::GetDescriptorError> for FormattedString {
|
||||
fn from(err: online::GetDescriptorError) -> Self {
|
||||
FormattedString(format!(
|
||||
"{}\n\n'{}'",
|
||||
"Symbol Lookup Failed:".red().bold(),
|
||||
|
|
@ -113,6 +133,16 @@ impl From<ServiceList> for FormattedString {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<Descriptor> for FormattedString {
|
||||
fn from(value: Descriptor) -> Self {
|
||||
match value {
|
||||
Descriptor::MessageDescriptor(d) => FormattedString::from(d),
|
||||
Descriptor::ServiceDescriptor(d) => FormattedString::from(d),
|
||||
Descriptor::EnumDescriptor(d) => FormattedString::from(d),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ServiceDescriptor> for FormattedString {
|
||||
fn from(service: ServiceDescriptor) -> Self {
|
||||
let mut out = String::new();
|
||||
|
|
|
|||
|
|
@ -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<std::path::PathBuf>,
|
||||
) -> 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<WithServerReflection<Channel>>,
|
||||
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<String> {
|
||||
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<WithFileDescriptor<Channel>>,
|
||||
command: Commands,
|
||||
) {
|
||||
match command {
|
||||
Commands::Call {
|
||||
endpoint,
|
||||
body,
|
||||
headers,
|
||||
} => {
|
||||
let (service, method) = endpoint;
|
||||
let request = DynamicRequest {
|
||||
body,
|
||||
headers,
|
||||
service,
|
||||
method,
|
||||
};
|
||||
|
||||
let response = unwrap_or_exit(client.dynamic(request).await);
|
||||
print_response(response);
|
||||
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<T, E>(result: Result<T, E>) -> T
|
||||
// Utility trait to standardize the way we handle errors in the program
|
||||
trait UnwrapOrExit<T, E> {
|
||||
fn unwrap_or_exit(self) -> T;
|
||||
}
|
||||
|
||||
impl<T, E> UnwrapOrExit<T, E> for Result<T, E>
|
||||
where
|
||||
E: Into<FormattedString>,
|
||||
{
|
||||
match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("{}", Into::<FormattedString>::into(e));
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_descriptor(descriptor: Descriptor) {
|
||||
match descriptor {
|
||||
Descriptor::MessageDescriptor(d) => println!("{}", FormattedString::from(d)),
|
||||
Descriptor::ServiceDescriptor(d) => println!("{}", FormattedString::from(d)),
|
||||
Descriptor::EnumDescriptor(d) => println!("{}", FormattedString::from(d)),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_response(response: DynamicResponse) {
|
||||
match response {
|
||||
DynamicResponse::Unary(Ok(value)) => println!("{}", FormattedString::from(value)),
|
||||
DynamicResponse::Unary(Err(status)) => println!("{}", FormattedString::from(status)),
|
||||
DynamicResponse::Streaming(Ok(values)) => {
|
||||
for elem in values {
|
||||
match elem {
|
||||
Ok(val) => println!("{}", FormattedString::from(val)),
|
||||
Err(status) => println!("{}", FormattedString::from(status)),
|
||||
}
|
||||
fn unwrap_or_exit(self) -> T {
|
||||
match self {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("{}", Into::<FormattedString>::into(e));
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
DynamicResponse::Streaming(Err(status)) => {
|
||||
println!("{}", FormattedString::from(status))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue