granc_core 0.5.0

Cranc gRPC CLI core library
Documentation

Granc Core

Crates.io Documentation License

granc-core is the foundational library powering the Granc CLI. It provides a dynamic gRPC client capability that allows you to interact with any gRPC server without needing compile-time Protobuf code generation.

Instead of strictly typed Rust structs, this library bridges standard serde_json::Value payloads directly to Protobuf binary wire format at runtime.

🚀 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.

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. Using Server Reflection (Default)

By default, when you connect, the client is ready to use the server's reflection service to resolve methods and types dynamically.

use granc_core::client::{GrancClient, DynamicRequest, DynamicResponse};
use serde_json::json;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Connect (starts in Reflection mode)
    let mut client = GrancClient::connect("http://localhost:50051").await?;

    let request = DynamicRequest {
        service: "helloworld.Greeter".to_string(),
        method: "SayHello".to_string(),
        body: json!({ "name": "World" }),
        headers: vec![],
    };

    // Execute (Schema is fetched automatically via reflection)
    let response = client.dynamic(request).await?;

    match response {
        DynamicResponse::Unary(Ok(value)) => println!("Response: {}", value),
        DynamicResponse::Unary(Err(status)) => eprintln!("gRPC Error: {:?}", status),
        _ => {}
    }

    Ok(())
}

2. Using a Local Descriptor File

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 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
    let mut client = client.with_file_descriptor(descriptor_bytes)?;

    // Now use this client for requests. It will NOT query the server for schema.
    let services = client.list_services();
    println!("Services in file: {:?}", services);

    Ok(())
}

3. Schema Introspection

Both client states expose methods to inspect the available schema, but their APIs differ slightly because reflection requires network calls (async) while file lookups are in-memory (sync).

Using Server Reflection (Async)

// List available services (requires network call)
let services = client.list_services().await?;

// Get a specific descriptor (requires network call)
// Returns Result<Descriptor, Error>
let descriptor = client.get_descriptor_by_symbol("helloworld.Greeter").await?;

match descriptor {
    Descriptor::ServiceDescriptor(svc) => println!("Service: {}", svc.name()),
    Descriptor::MessageDescriptor(msg) => println!("Message: {}", msg.name()),
    Descriptor::EnumDescriptor(enm) => println!("Enum: {}", enm.name()),
}

Using Local File (Sync)

// 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

We expose the internal building blocks of granc for developers who need more granular control or want to build their own tools on top of our dynamic transport layer.

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
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?;

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.

⚖️ License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.