From 7bc2e4c0a983421beae6a5f2d44e00a74ec878c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= <49537445+JasterV@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:07:10 +0100 Subject: [PATCH] refactor: separate core logic into a library crate `granc-core` (#16) This pull request introduces a significant internal refactor of the `granc` project, decoupling core dynamic gRPC client logic into a new reusable library crate (`granc-core`). It also improves project organization, updates documentation, and enhances workspace configuration. The main CLI functionality is now built atop this new core, making future maintenance and extensibility easier. **Project structure and workspace improvements:** - Created a new crate, `granc-core`, to encapsulate all core dynamic gRPC client logic, including schema resolution, dynamic request dispatch, and reflection support. This enables potential reuse outside the CLI and clarifies project boundaries. (`granc-core/Cargo.toml`, `granc-core/src/client.rs`, [[1]](diffhunk://#diff-dd6f7ed591a1bd2577444d0079c1f56851ef74e3b9df75a86ef4af76681435f6R1-R126) [[2]](diffhunk://#diff-ddab7585cf4c860c9922ed56471bccf5804da60f0ccb174158fd31b9b82457abR1-R46) [[3]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adR1-R186) - Updated workspace configuration in `Cargo.toml` to include `granc-core`, centralize dependency versions, and set workspace-wide package metadata for consistency. (`Cargo.toml`, [Cargo.tomlL2-R26](diffhunk://#diff-2e9d962a08321605940b5a657135052fbcef87b5e360662bb527c96d9a615542L2-R26)) - Adjusted `echo-service` and other crates to use workspace-wide settings for edition and authors. (`echo-service/Cargo.toml`, [echo-service/Cargo.tomlL3-R3](diffhunk://#diff-e74eb8a3bebf341a9bee1cdcd5cd3a50e15998db5a9df9eaf9e7aec341287b1eL3-R3)) **Documentation :** - Added a detailed `README.md` for both the main project and the new `granc-core` library, providing clear installation, usage, and architecture guidance for users and contributors. (`README.md`, `granc-core/README.md`, [[1]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R1-R172) [[2]](diffhunk://#diff-dd6f7ed591a1bd2577444d0079c1f56851ef74e3b9df75a86ef4af76681435f6R1-R126) **Build and tooling updates:** - Updated `Makefile.toml` to use workspace-wide test runs and renamed tasks/binaries for consistency with the new crate layout. (`Makefile.toml`, [[1]](diffhunk://#diff-9375fd04332c86472d7be397ef09428cb86babd8826880a5835bd1d1c1bdbc08L18-R18) [[2]](diffhunk://#diff-9375fd04332c86472d7be397ef09428cb86babd8826880a5835bd1d1c1bdbc08L45-R50) --- **Key changes:** **1. Core library extraction and refactor** - Moved dynamic gRPC client logic (including `GrancClient`, request/response types, and reflection handling) into a new `granc-core` crate, decoupling it from the CLI and preparing for independent publishing. [[1]](diffhunk://#diff-ddab7585cf4c860c9922ed56471bccf5804da60f0ccb174158fd31b9b82457abR1-R46) [[2]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adR1-R186) **2. Workspace and dependency management** - Updated the root `Cargo.toml` to add `granc-core` as a workspace member, centralize dependency versions, and set workspace-wide metadata fields (authors, edition, license, etc.). - Adjusted `echo-service` and new crates to inherit workspace settings for consistency. **3. Documentation** - Updated and added comprehensive `README.md` files for both the main project and the new core library, with installation, usage, and architecture sections. [[1]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R1-R172) [[2]](diffhunk://#diff-dd6f7ed591a1bd2577444d0079c1f56851ef74e3b9df75a86ef4af76681435f6R1-R126) - Introduced a `CHANGELOG.md` to document project history and recent changes. **4. Build and CI tooling** - Updated test and generation commands in `Makefile.toml` to reflect the new workspace structure and binary names. [[1]](diffhunk://#diff-9375fd04332c86472d7be397ef09428cb86babd8826880a5835bd1d1c1bdbc08L18-R18) [[2]](diffhunk://#diff-9375fd04332c86472d7be397ef09428cb86babd8826880a5835bd1d1c1bdbc08L45-R50) **5. Housekeeping** - Removed outdated or redundant files as part of the refactor. [[1]](diffhunk://#diff-06572a96a58dc510037d5efa622f9bec8519bc1beab13c9f251e97e657a9d4edL1) [[2]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5L1) This refactor lays the groundwork for improved maintainability, easier future development, and potential wider adoption of the dynamic gRPC client logic outside the CLI. --- CHANGELOG.md | 48 +++- Cargo.lock | 14 +- Cargo.toml | 25 +- Makefile.toml | 8 +- README.md | 173 +++++++++++- echo-service/Cargo.toml | 2 +- granc-core/Cargo.toml | 46 ++++ granc-core/README.md | 126 +++++++++ .../bin/generate_reflection_service.rs | 0 {granc => granc-core}/proto/reflection.proto | 0 granc-core/src/client.rs | 186 +++++++++++++ granc-core/src/grpc.rs | 10 + .../core => granc-core/src/grpc}/client.rs | 148 ++++------- .../src/core => granc-core/src/grpc}/codec.rs | 0 granc-core/src/lib.rs | 50 ++++ granc-core/src/reflection.rs | 8 + granc-core/src/reflection/client.rs | 223 ++++++++++++++++ .../src}/reflection/generated.rs | 0 .../generated/grpc.reflection.v1.rs | 0 .../tests/dummy_echo_service_impl.rs | 6 +- .../tests}/echo_service_impl.rs | 1 - granc-core/tests/granc_client_test.rs | 128 +++++++++ granc-core/tests/reflection_client_test.rs | 138 ++++++++++ granc/CHANGELOG.md | 47 ---- granc/Cargo.toml | 51 +--- granc/README.md | 171 ------------ granc/src/cli.rs | 27 +- granc/src/core.rs | 162 ----------- granc/src/core/client/integration_test.rs | 118 -------- granc/src/core/reflection.rs | 6 - granc/src/core/reflection/client.rs | 251 ------------------ .../reflection/client/integration_test.rs | 192 -------------- granc/src/core/reflection/registry.rs | 71 ----- granc/src/main.rs | 31 ++- release-plz.toml | 2 +- 35 files changed, 1285 insertions(+), 1184 deletions(-) mode change 120000 => 100644 CHANGELOG.md mode change 120000 => 100644 README.md create mode 100644 granc-core/Cargo.toml create mode 100644 granc-core/README.md rename {granc => granc-core}/bin/generate_reflection_service.rs (100%) rename {granc => granc-core}/proto/reflection.proto (100%) create mode 100644 granc-core/src/client.rs create mode 100644 granc-core/src/grpc.rs rename {granc/src/core => granc-core/src/grpc}/client.rs (56%) rename {granc/src/core => granc-core/src/grpc}/codec.rs (100%) create mode 100644 granc-core/src/lib.rs create mode 100644 granc-core/src/reflection.rs create mode 100644 granc-core/src/reflection/client.rs rename {granc/src/core => granc-core/src}/reflection/generated.rs (100%) rename {granc/src/core => granc-core/src}/reflection/generated/grpc.reflection.v1.rs (100%) rename granc/src/core/reflection/client/integration_test/dummy_service.rs => granc-core/tests/dummy_echo_service_impl.rs (94%) rename {granc/src/core/client/integration_test => granc-core/tests}/echo_service_impl.rs (99%) create mode 100644 granc-core/tests/granc_client_test.rs create mode 100644 granc-core/tests/reflection_client_test.rs delete mode 100644 granc/CHANGELOG.md delete mode 100644 granc/README.md delete mode 100644 granc/src/core.rs delete mode 100644 granc/src/core/client/integration_test.rs delete mode 100644 granc/src/core/reflection.rs delete mode 100644 granc/src/core/reflection/client.rs delete mode 100644 granc/src/core/reflection/client/integration_test.rs delete mode 100644 granc/src/core/reflection/registry.rs diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 120000 index 7fc86d0..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1 +0,0 @@ -granc/CHANGELOG.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..51e4dd0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,47 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## `granc` - [0.2.3](https://github.com/JasterV/granc/compare/granc-v0.2.2...granc-v0.2.3) - 2026-01-21 + +### Other + +- **Internal refactor**: Decouple ReflectionClient to possibly publish in a separate crate + +## `granc` - [0.2.2](https://github.com/JasterV/granc/compare/granc-v0.2.1...granc-v0.2.2) - 2026-01-21 + +### Other + +- Update README.md + +## `granc` - [0.2.1](https://github.com/JasterV/granc/compare/granc-v0.2.0...granc-v0.2.1) - 2026-01-21 + +### Other + +- Update README + +## `granc` - [0.2.0](https://github.com/JasterV/granc/compare/granc-v0.1.0...granc-v0.2.0) - 2026-01-21 + +### Added + +- **Automatic Reflection**: The tool now supports automatic reflection, trying to reach the reflection service in the server if the user doesn't provide a file descriptor binary ([#9](https://github.com/JasterV/granc/pull/9)) + +## `granc` - 0.1.0 2026-01-20 + +### Added + +- **Dynamic gRPC Client**: Implemented a CLI that performs gRPC calls without generating Rust code, bridging JSON payloads to Protobuf binary format at runtime. +- **Schema Loading**: Support for loading Protobuf schemas dynamically from binary `FileDescriptorSet` (`.bin` or `.pb`) files. +- **Full Streaming Support**: Automatic dispatch for all four gRPC access patterns based on the method descriptor: + - Unary (Single Request → Single Response) + - Server Streaming (Single Request → Stream) + - Client Streaming (Stream → Single Response) + - Bidirectional Streaming (Stream → Stream) +- **JSON Transcoding**: Custom `tonic::Codec` implementation (`JsonCodec`) to validate and transcode `serde_json::Value` to/from Protobuf bytes on the fly. +- **Metadata Support**: Ability to attach custom headers/metadata to requests via the `-H` / `--header` flag. +- **Input Validation**: Fast-fail validation that checks if the provided JSON structure is valid before making the network request. diff --git a/Cargo.lock b/Cargo.lock index 1d3454a..d4ac876 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -330,6 +330,16 @@ name = "granc" version = "0.2.3" dependencies = [ "clap", + "granc_core", + "serde_json", + "tokio", + "tonic", +] + +[[package]] +name = "granc_core" +version = "0.2.3" +dependencies = [ "echo-service", "futures-util", "http", @@ -1283,6 +1293,6 @@ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "zmij" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" diff --git a/Cargo.toml b/Cargo.toml index 8ef8b2a..972b7d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,26 @@ [workspace] -members = ["granc", "echo-service"] +members = ["granc", "granc-core", "echo-service"] resolver = "2" + +[workspace.package] +authors = ["Victor Martínez Montané "] +edition = "2024" +homepage = "https://github.com/JasterV/granc" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JasterV/granc" +rust-version = "1.89" +version = "0.2.3" + +[workspace.dependencies] +serde_json = "1.0.149" +tokio = { version = "1.49.0" } + +# Tonic & prost related deps +# They must be updated all at once +prost = "0.14" +prost-reflect = "0.16.3" +prost-types = "0.14" +tonic = "0.14" +tonic-prost = "0.14" +tonic-prost-build = "0.14" +tonic-reflection = "0.14" diff --git a/Makefile.toml b/Makefile.toml index 667a0b7..19927cc 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -15,8 +15,7 @@ args = ["run", "-p", "granc", "${@}"] [tasks.test] description = "Runs tests for the granc crate only" command = "cargo" -# Added '-p granc' to strictly run integration/unit tests for the CLI -args = ["nextest", "run", "--no-fail-fast", "-p", "granc"] +args = ["nextest", "run", "--no-fail-fast", "--workspace"] [tasks.fmt] description = "Formats all source files" @@ -42,14 +41,13 @@ args = [ "warnings", ] - -[tasks.generate_reflection_service] +[tasks.generate-reflection-service] description = "Runs a binary that generates a reflection service client from the reflection proto definitions" command = "cargo" args = [ "run", "--bin", - "generate_reflection_service", + "generate-reflection-service", "--features", "gen-proto", ] diff --git a/README.md b/README.md deleted file mode 120000 index 9e8f270..0000000 --- a/README.md +++ /dev/null @@ -1 +0,0 @@ -granc/README.md \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..353f49a --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +# Granc 🦀 + +[![granc on crates.io](https://img.shields.io/crates/v/granc)](https://crates.io/crates/granc) +[![License](https://img.shields.io/crates/l/granc.svg)](https://github.com/JasterV/granc/blob/main/LICENSE) + +> ⚠️ **Status: Experimental** +> +> This project is currently in a **highly experimental phase**. It is a working prototype intended for testing and development purposes. APIs, command-line arguments, and internal logic are subject to breaking changes. Please use with caution. + +**Granc** (gRPC + Cranc, Crab in Catalan) is a lightweight, dynamic gRPC CLI tool written in Rust. + +It allows you to make gRPC calls to any server using simple JSON payloads, without needing to compile the specific Protobuf files into the client. By loading a `FileDescriptorSet` at runtime, granc acts as a bridge between human-readable JSON and binary Protobuf wire format. + +It is heavily inspired by tools like `grpcurl` but built to leverage the safety and performance of the Rust ecosystem (Tonic + Prost). + +## 🚀 Features + +* **Dynamic Encoding/Decoding**: Transcodes JSON to Protobuf (and vice versa) on the fly using `prost-reflect`. +* **Smart Dispatch**: Automatically detects if a call is Unary, Server Streaming, Client Streaming, or Bidirectional based on the descriptor. +* **Server Reflection**: Can fetch schemas directly from the server, removing the need to pass a local file descriptor set file (`.bin` or `.pb`). +* **Metadata Support**: Easily attach custom headers (authorization, tracing) to your requests. +* **Fast Fail Validation**: Validates your JSON *before* hitting the network. +* **Zero Compilation Dependencies**: Does not require generating Rust code for your protos. Just point to a descriptor file. +* **Tonic 0.14**: Built on the latest stable Rust gRPC stack. + +## 📦 Installation + +### From Crates.io + +```bash +cargo install granc +``` + +### From Source + +Ensure you have Rust and Cargo installed. + +```bash +git clone https://github.com/JasterV/granc +cd granc +cargo install --path . +``` + +## 🛠️ Prerequisites + +Granc needs to know the schema of the service you are calling. It can obtain this in two ways: + +1. **Automatic Server Reflection**: If the server has [Server Reflection](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md) enabled, Granc can download the schema automatically. +2. **Local Descriptor File**: You can provide a binary `FileDescriptorSet` (`.bin`) generated by `protoc`. + +### Generating Descriptors (Optional) + +If your server does not support reflection, you must generate a descriptor file: + +```bash +# Generate descriptor.bin including all imports +protoc \ + --include_imports \ + --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. + +## 📖 Usage + +**Syntax:** + +```bash +granc [OPTIONS] +``` + +### Arguments + +| Argument | Description | Required | +| --- | --- | --- | +| `` | Server address (e.g., `http://[::1]:50051`). | **Yes** | +| `` | Fully qualified method name (e.g., `my.package.Service/Method`). | **Yes** | + +### Options + +| Flag | Short | Description | Required | +| --- | --- | --- | --- | +| `--proto-set` | | Path to the binary FileDescriptorSet (`.bin`). | **No** | +| `--body` | | The request body in JSON format. | **Yes** | +| `--header` | `-H` | Custom header `key:value`. Can be used multiple times. | No | + +### Automatic Server Reflection + +If you omit the `--proto-set` flag, Granc will automatically attempt to connect to the server's reflection service to download the necessary schemas. + +```bash +# Using Reflection (no descriptor file needed) +granc \ + --body '{"name": "Ferris"}' \ + http://localhost:50051 \ + helloworld.Greeter/SayHello +``` + +This requires the server to have the [`grpc.reflection.v1`](https://github.com/grpc/grpc-proto/blob/master/grpc/reflection/v1/reflection.proto) service enabled. + +### JSON Body Format + +* **Unary / Server Streaming**: Provide a single JSON object `{ ... }`. +* **Client / Bidirectional Streaming**: Provide a JSON array of objects `[ { ... }, { ... } ]`. + +### Examples + +**1. Unary Call (using local descriptor)** + +```bash +granc \ + --proto-set ./descriptor.bin \ + --body '{"name": "Ferris"}' \ + http://localhost:50051 \ + helloworld.Greeter/SayHello +``` + +**2. Bidirectional Streaming (Chat)** + +```bash +granc \ + --body '[{"text": "Hello"}, {"text": "How are you?"}]' \ + -H "authorization: Bearer token123" \ + http://localhost:50051 \ + chat.ChatService/StreamMessages +``` + +## 🔮 Roadmap + +* **Interactive Mode**: A REPL for streaming requests interactively. +* **Pretty Printing**: Enhanced colored output for JSON responses. +* **TLS Support**: Configurable root certificates and client identity. + +## ⚠️ Common Errors + +**1. `Service 'x' not found**` + +* **Cause:** The service name in the command does not match the package defined in your proto file. +* **Fix:** Check your `.proto` file. If it has `package my.app;` and `service API {}`, the full name is `my.app.API`. + +**2. `Method 'y' not found in service 'x'**` + +* **Cause:** Typo in the method name or the method doesn't exist. +* **Fix:** Ensure case sensitivity matches (e.g., `GetUser` vs `getUser`). + +**3. `h2 protocol error**` + +* **Cause:** This often occurs when the JSON payload fails to encode *after* the connection has already been established, or the server rejected the stream structure. +* **Fix:** Double-check your JSON payload against the Protobuf schema. + +## 🤝 Contributing + +Contributions are welcome! Please run the Makefile checks before submitting a PR: + +```bash +cargo make ci # Checks formatting, lints, and runs tests +``` + +## 📄 License + +Licensed under either of: + +* Apache License, Version 2.0 ([LICENSE-APACHE](http://www.apache.org/licenses/LICENSE-2.0)) +* MIT license ([LICENSE-MIT](http://opensource.org/licenses/MIT)) + +at your option. + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. diff --git a/echo-service/Cargo.toml b/echo-service/Cargo.toml index 3b53e4c..9c93caa 100644 --- a/echo-service/Cargo.toml +++ b/echo-service/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "echo-service" -edition = "2024" +edition = { workspace = true } publish = false [dependencies] diff --git a/granc-core/Cargo.toml b/granc-core/Cargo.toml new file mode 100644 index 0000000..99d0580 --- /dev/null +++ b/granc-core/Cargo.toml @@ -0,0 +1,46 @@ +[package] +authors = { workspace = true } +categories = ["network-programming"] +description = "Cranc gRPC CLI core library" +edition = { workspace = true } +homepage = { workspace = true } +keywords = ["grpc", "network-programming", "grpc-reflection"] +license = { workspace = true } +name = "granc_core" +publish = true +readme = "README.md" +repository = { workspace = true } +rust-version = { workspace = true } +version = { workspace = true } + +[features] +gen-proto = ["dep:tonic-prost-build"] + +[lib] +name = "granc_core" +path = "src/lib.rs" + +[[bin]] +name = "generate-reflection-service" +path = "bin/generate_reflection_service.rs" +required-features = ["gen-proto"] + +[dependencies] +futures-util = "0.3.31" +http = "1.4.0" +http-body = "1.0.1" +prost = { workspace = true } +prost-reflect = { workspace = true, features = ["serde"] } +prost-types = { workspace = true } +serde_json = { workspace = true } +thiserror = "2.0.18" +tokio = { workspace = true, features = ["sync"] } +tokio-stream = "0.1.18" +tonic = { workspace = true } +tonic-prost = { workspace = true } +tonic-prost-build = { workspace = true, optional = true } + +[dev-dependencies] +echo-service = { path = "../echo-service" } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tonic-reflection = { workspace = true } diff --git a/granc-core/README.md b/granc-core/README.md new file mode 100644 index 0000000..c0fe15a --- /dev/null +++ b/granc-core/README.md @@ -0,0 +1,126 @@ +# Granc Core + +[![Crates.io](https://img.shields.io/crates/v/granc_core.svg)](https://crates.io/crates/granc_core) +[![Documentation](https://docs.rs/granc_core/badge.svg)](https://docs.rs/granc_core) +[![License](https://img.shields.io/crates/l/granc_core.svg)](https://github.com/JasterV/granc/blob/main/LICENSE) + +**`granc-core`** is the foundational library powering the [Granc CLI](https://crates.io/crates/granc). 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. + +## 📦 Installation + +Add this to your `Cargo.toml`: + +```toml +[dependencies] +granc_core = "0.2.3" +tokio = { version = "1", features = ["full"] } +serde_json = "1" +``` + +## 🚀 High-Level Usage + +The primary entry point is the [`GrancClient`]. It acts as an orchestrator that: + +1. Connects to a gRPC server. +2. Resolves the schema (either from a local file or via Server Reflection). +3. Determines the method type (Unary, Server Streaming, etc.). +4. Execute the request using JSON. + +### Example: Making a Dynamic Call + +```rust +use granc_core::client::{GrancClient, DynamicRequest, DynamicResponse}; +use serde_json::json; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Connect to the server + let mut client = GrancClient::connect("http://localhost:50051").await?; + + // Prepare the request + // If you don't provide a file_descriptor_set, the client will attempt + // to fetch the schema from the server's reflection service automatically. + let request = DynamicRequest { + service: "helloworld.Greeter".to_string(), + method: "SayHello".to_string(), + body: json!({ "name": "World" }), + headers: vec![], + file_descriptor_set: None, // Uses Server Reflection + }; + + let response = client.dynamic(request).await?; + + match response { + DynamicResponse::Unary(Ok(value)) => { + println!("Response: {}", value); + } + DynamicResponse::Unary(Err(status)) => { + eprintln!("gRPC Error: {:?}", status); + } + DynamicResponse::Streaming(Ok(stream)) => { + for msg in stream { + println!("Stream Msg: {:?}", msg); + } + } + _ => eprintln!("Unexpected response type"), + } + + Ok(()) +} + +``` + +## 🛠️ 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` + +```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?; + +``` + +### 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. + +```rust +use granc_core::reflection::client::ReflectionClient; + +let mut reflection = ReflectionClient::new(channel); +let fd_set = reflection.file_descriptor_set_by_symbol("my.package.Service").await?; +``` + +You can then build a `prost_reflect::DescriptorPool` with the returned `prost_types::FileDescriptorSet` to be able to inspect in detail the descriptor. + +## ⚖️ License + +Licensed under either of Apache License, Version 2.0 or MIT license at your option. diff --git a/granc/bin/generate_reflection_service.rs b/granc-core/bin/generate_reflection_service.rs similarity index 100% rename from granc/bin/generate_reflection_service.rs rename to granc-core/bin/generate_reflection_service.rs diff --git a/granc/proto/reflection.proto b/granc-core/proto/reflection.proto similarity index 100% rename from granc/proto/reflection.proto rename to granc-core/proto/reflection.proto diff --git a/granc-core/src/client.rs b/granc-core/src/client.rs new file mode 100644 index 0000000..90308be --- /dev/null +++ b/granc-core/src/client.rs @@ -0,0 +1,186 @@ +//! # Granc Client +//! +//! This module implements the high-level logic for executing dynamic gRPC requests. +//! It acts as the bridge between the user's intent (a JSON body and a method name) +//! and the low-level gRPC transport. +//! +//! ## Responsibilities +//! +//! 1. **Schema Resolution**: It determines whether to use a provided `FileDescriptorSet` +//! or to fetch the schema dynamically using the [`crate::reflection::client::ReflectionClient`]. +//! 2. **Method Lookup**: It validates that the requested service and method exist within +//! the resolved schema. +//! 3. **Dispatch**: It inspects the method descriptor to determine the correct gRPC access +//! pattern (Unary, Server Streaming, Client Streaming, or Bidirectional) and routes +//! the request accordingly. +//! 4. **Input Adaptation**: It converts input JSON data into the appropriate stream format +//! required by the underlying transport. +use crate::{ + BoxError, + grpc::client::{GrpcClient, GrpcRequestError}, + reflection::client::{ReflectionClient, ReflectionResolveError}, +}; +use futures_util::Stream; +use http_body::Body as HttpBody; +use prost_reflect::{DescriptorError, DescriptorPool}; +use tokio_stream::StreamExt; +use tonic::transport::{Channel, Endpoint}; + +#[derive(Debug, thiserror::Error)] +pub enum ClientConnectError { + #[error("Invalid URL '{0}': {1}")] + InvalidUrl(String, #[source] tonic::transport::Error), + #[error("Failed to connect to '{0}': {1}")] + ConnectionFailed(String, #[source] tonic::transport::Error), +} + +#[derive(Debug, thiserror::Error)] +pub enum DynamicRequestError { + #[error("Invalid input: '{0}'")] + InvalidInput(String), + + #[error("Failed to read descriptor file: '{0}'")] + Io(#[from] std::io::Error), + + #[error("Service '{0}' not found")] + ServiceNotFound(String), + + #[error("Method '{0}' not found")] + MethodNotFound(String), + + #[error("Reflection resolution failed: '{0}'")] + ReflectionResolve(#[from] ReflectionResolveError), + + #[error("Failed to decode file descriptor set: '{0}'")] + DescriptorError(#[from] DescriptorError), + + #[error("gRPC client request error: '{0}'")] + GrpcRequestError(#[from] GrpcRequestError), +} + +pub struct DynamicRequest { + pub file_descriptor_set: Option>, + pub body: serde_json::Value, + pub headers: Vec<(String, String)>, + pub service: String, + pub method: String, +} + +pub enum DynamicResponse { + Unary(Result), + Streaming(Result>, tonic::Status>), +} + +pub struct GrancClient { + reflection_client: ReflectionClient, + grpc_client: GrpcClient, +} + +impl GrancClient { + pub async fn connect(addr: &str) -> Result { + let endpoint = Endpoint::new(addr.to_string()) + .map_err(|e| ClientConnectError::InvalidUrl(addr.to_string(), e))?; + + let channel = endpoint + .connect() + .await + .map_err(|e| ClientConnectError::ConnectionFailed(addr.to_string(), e))?; + + Ok(Self::new(channel)) + } +} + +impl GrancClient +where + S: tonic::client::GrpcService + Clone, + S::ResponseBody: HttpBody + Send + 'static, + ::Error: Into + Send, +{ + pub fn new(service: S) -> Self { + let reflection_client = ReflectionClient::new(service.clone()); + let grpc_client = GrpcClient::new(service); + + Self { + reflection_client, + grpc_client, + } + } + + pub async fn dynamic( + &mut self, + request: DynamicRequest, + ) -> Result { + let pool = match request.file_descriptor_set { + Some(bytes) => DescriptorPool::decode(bytes.as_slice())?, + // If no proto-set file is passed, we'll try to reach the server reflection service + None => { + let fd_set = self + .reflection_client + .file_descriptor_set_by_symbol(&request.service) + .await?; + DescriptorPool::from_file_descriptor_set(fd_set)? + } + }; + + let service = pool + .get_service_by_name(&request.service) + .ok_or_else(|| DynamicRequestError::ServiceNotFound(request.service))?; + + let method = service + .methods() + .find(|m| m.name() == request.method) + .ok_or_else(|| DynamicRequestError::MethodNotFound(request.method))?; + + match (method.is_client_streaming(), method.is_server_streaming()) { + (false, false) => { + let result = self + .grpc_client + .unary(method, request.body, request.headers) + .await?; + Ok(DynamicResponse::Unary(result)) + } + + (false, true) => { + match self + .grpc_client + .server_streaming(method, request.body, request.headers) + .await? + { + Ok(stream) => Ok(DynamicResponse::Streaming(Ok(stream.collect().await))), + Err(status) => Ok(DynamicResponse::Streaming(Err(status))), + } + } + (true, false) => { + let input_stream = json_array_to_stream(request.body) + .map_err(DynamicRequestError::InvalidInput)?; + let result = self + .grpc_client + .client_streaming(method, input_stream, request.headers) + .await?; + Ok(DynamicResponse::Unary(result)) + } + + (true, true) => { + let input_stream = json_array_to_stream(request.body) + .map_err(DynamicRequestError::InvalidInput)?; + match self + .grpc_client + .bidirectional_streaming(method, input_stream, request.headers) + .await? + { + Ok(stream) => Ok(DynamicResponse::Streaming(Ok(stream.collect().await))), + Err(status) => Ok(DynamicResponse::Streaming(Err(status))), + } + } + } + } +} + +fn json_array_to_stream( + json: serde_json::Value, +) -> Result + Send + 'static, String> { + match json { + serde_json::Value::Array(items) => Ok(tokio_stream::iter(items)), + _ => Err("Client streaming requires a JSON Array body".to_string()), + } +} diff --git a/granc-core/src/grpc.rs b/granc-core/src/grpc.rs new file mode 100644 index 0000000..c74b243 --- /dev/null +++ b/granc-core/src/grpc.rs @@ -0,0 +1,10 @@ +//! # Generic gRPC Transport +//! +//! This module contains the low-level building blocks for performing gRPC calls using +//! dynamic message types. +//! +//! Unlike standard `tonic` clients which are strongly typed (e.g., `HelloRequest`), +//! the components here are designed to work with generic `serde_json::Value` structures, +//! transcoding them to Protobuf binary format on the fly. +pub mod client; +pub mod codec; diff --git a/granc/src/core/client.rs b/granc-core/src/grpc/client.rs similarity index 56% rename from granc/src/core/client.rs rename to granc-core/src/grpc/client.rs index abb3d90..29d6648 100644 --- a/granc/src/core/client.rs +++ b/granc-core/src/grpc/client.rs @@ -1,29 +1,29 @@ //! # Generic gRPC Client //! -//! This module provides a wrapper around `tonic::client::Grpc` to perform network calls -//! without generated Rust types. +//! This module wraps a standard `tonic` client to provide a generic interface for +//! gRPC communication. It is agnostic to the specific Protobuf messages being exchanged. //! -//! Unlike a standard gRPC client where types are known at compile time, this client -//! uses the `JsonCodec` to serialize/deserialize data dynamically based on `MethodDescriptor`s. +//! ## How it works //! -//! ## Key Responsibilities +//! The [`GrpcClient`] utilizes the [`super::codec::JsonCodec`] to handle serialization. +//! It does not need to know the structure of the data it is sending; it simply ensures +//! the connection is established and passes the `serde_json::Value` and `MethodDescriptor` +//! to the codec. //! -//! * **Connection Management**: Establishes the channel to the remote server. -//! * **Dynamic Dispatch**: Provides methods (`unary`, `server_streaming`, etc.) that accept -//! raw `serde_json::Value` and `prost_reflect::MethodDescriptor`. -//! * **Metadata Handling**: Converts generic string headers into `tonic::metadata` types. +//! ## Features //! -use crate::core::BoxError; - +//! * **Dynamic Pathing**: Constructs the HTTP/2 path (e.g., `/package.Service/Method`) at runtime. +//! * **Metadata Handling**: Converts standard Rust string tuples into Tonic's `MetadataMap` for headers. +//! * **Access Patterns**: Provides specific methods for Unary, Server Streaming, Client Streaming, +//! and Bidirectional Streaming calls. use super::codec::JsonCodec; +use crate::BoxError; use futures_util::Stream; use http_body::Body as HttpBody; use prost_reflect::MethodDescriptor; use std::str::FromStr; -use thiserror::Error; use tonic::{ - Request, - client::Grpc, + client::GrpcService, metadata::{ MetadataKey, MetadataValue, errors::{InvalidMetadataKey, InvalidMetadataValue}, @@ -31,18 +31,8 @@ use tonic::{ transport::Channel, }; -#[cfg(test)] -mod integration_test; - -#[derive(Error, Debug)] -pub enum ClientError { - #[error("Invalid uri '{addr}' provided: '{source}'")] - InvalidUri { - addr: String, - source: http::uri::InvalidUri, - }, - #[error("Failed to connect: '{0}'")] - ConnectionFailed(tonic::transport::Error), +#[derive(thiserror::Error, Debug)] +pub enum GrpcRequestError { #[error("Internal error, the client was not ready: '{0}'")] ClientNotReady(#[source] BoxError), #[error("Invalid metadata (header) key '{key}': '{source}'")] @@ -57,43 +47,23 @@ pub enum ClientError { }, } -/// A generic gRPC client that uses dynamic dispatch via `prost-reflect`. -/// -/// It can wrap any inner service `T` that implements `GrpcService`, -/// such as `tonic::transport::Channel` or a service generated by `tonic-build`. -#[derive(Clone)] -pub struct GrpcClient { - service: T, -} - -/// Implementation for the standard network client using `Channel`. -impl GrpcClient { - /// Connects to the specified gRPC server address. - /// - /// # Arguments - /// * `addr` - The URI of the server (e.g., `http://localhost:50051`). - pub async fn connect(addr: &str) -> Result { - let uri = - tonic::transport::Uri::from_str(addr).map_err(|source| ClientError::InvalidUri { - addr: addr.to_string(), - source, - })?; - - let channel = Channel::builder(uri) - .connect() - .await - .map_err(ClientError::ConnectionFailed)?; - - Ok(Self { service: channel }) - } +/// A generic client for the gRPC Server Reflection Protocol. +pub struct GrpcClient { + client: tonic::client::Grpc, } impl GrpcClient where - S: tonic::client::GrpcService + Clone, - S::ResponseBody: HttpBody + Send + 'static, - ::Error: Into, + S: GrpcService, + S::Error: Into, + S::ResponseBody: HttpBody + Send + 'static, + ::Error: Into + Send, { + pub fn new(service: S) -> Self { + let client = tonic::client::Grpc::new(service); + Self { client } + } + /// Performs a Unary gRPC call (Single Request -> Single Response). /// /// # Returns @@ -101,22 +71,21 @@ where /// * `Ok(Err(Status))` - RPC executed, but server returned an error. /// * `Err(ClientError)` - Failed to send request or connect. pub async fn unary( - &self, + &mut self, method: MethodDescriptor, payload: serde_json::Value, headers: Vec<(String, String)>, - ) -> Result, ClientError> { - let mut client = Grpc::new(self.service.clone()); - client + ) -> Result, GrpcRequestError> { + self.client .ready() .await - .map_err(|e| ClientError::ClientNotReady(e.into()))?; + .map_err(|e| GrpcRequestError::ClientNotReady(e.into()))?; let codec = JsonCodec::new(method.input(), method.output()); let path = http_path(&method); let request = build_request(payload, headers)?; - match client.unary(request, path, codec).await { + match self.client.unary(request, path, codec).await { Ok(response) => Ok(Ok(response.into_inner())), Err(status) => Ok(Err(status)), } @@ -130,25 +99,24 @@ where /// * `Ok(Err(Status))` - RPC executed, but server returned an error. /// * `Err(ClientError)` - Failed to send request or connect. pub async fn server_streaming( - &self, + &mut self, method: MethodDescriptor, payload: serde_json::Value, headers: Vec<(String, String)>, ) -> Result< Result>, tonic::Status>, - ClientError, + GrpcRequestError, > { - let mut client = Grpc::new(self.service.clone()); - client + self.client .ready() .await - .map_err(|e| ClientError::ClientNotReady(e.into()))?; + .map_err(|e| GrpcRequestError::ClientNotReady(e.into()))?; let codec = JsonCodec::new(method.input(), method.output()); let path = http_path(&method); let request = build_request(payload, headers)?; - match client.server_streaming(request, path, codec).await { + match self.client.server_streaming(request, path, codec).await { Ok(response) => Ok(Ok(response.into_inner())), Err(status) => Ok(Err(status)), } @@ -162,22 +130,21 @@ where /// * `Ok(Err(Status))` - RPC executed, but server returned an error. /// * `Err(ClientError)` - Failed to send request or connect. pub async fn client_streaming( - &self, + &mut self, method: MethodDescriptor, payload_stream: impl Stream + Send + 'static, headers: Vec<(String, String)>, - ) -> Result, ClientError> { - let mut client = Grpc::new(self.service.clone()); - client + ) -> Result, GrpcRequestError> { + self.client .ready() .await - .map_err(|e| ClientError::ClientNotReady(e.into()))?; + .map_err(|e| GrpcRequestError::ClientNotReady(e.into()))?; let codec = JsonCodec::new(method.input(), method.output()); let path = http_path(&method); let request = build_request(payload_stream, headers)?; - match client.client_streaming(request, path, codec).await { + match self.client.client_streaming(request, path, codec).await { Ok(response) => Ok(Ok(response.into_inner())), Err(status) => Ok(Err(status)), } @@ -191,25 +158,24 @@ where /// * `Ok(Err(Status))` - RPC executed, but server returned an error. /// * `Err(ClientError)` - Failed to send request or connect. pub async fn bidirectional_streaming( - &self, + &mut self, method: MethodDescriptor, payload_stream: impl Stream + Send + 'static, headers: Vec<(String, String)>, ) -> Result< Result>, tonic::Status>, - ClientError, + GrpcRequestError, > { - let mut client = Grpc::new(self.service.clone()); - client + self.client .ready() .await - .map_err(|e| ClientError::ClientNotReady(e.into()))?; + .map_err(|e| GrpcRequestError::ClientNotReady(e.into()))?; let codec = JsonCodec::new(method.input(), method.output()); let path = http_path(&method); let request = build_request(payload_stream, headers)?; - match client.streaming(request, path, codec).await { + match self.client.streaming(request, path, codec).await { Ok(response) => Ok(Ok(response.into_inner())), Err(status) => Ok(Err(status)), } @@ -221,15 +187,19 @@ fn http_path(method: &MethodDescriptor) -> http::uri::PathAndQuery { http::uri::PathAndQuery::from_str(&path).expect("valid gRPC path") } -fn build_request(payload: T, headers: Vec<(String, String)>) -> Result, ClientError> { - let mut request = Request::new(payload); +fn build_request( + payload: T, + headers: Vec<(String, String)>, +) -> Result, GrpcRequestError> { + let mut request = tonic::Request::new(payload); for (k, v) in headers { - let key = MetadataKey::from_str(&k).map_err(|source| ClientError::InvalidMetadataKey { - key: k.clone(), - source, - })?; + let key = + MetadataKey::from_str(&k).map_err(|source| GrpcRequestError::InvalidMetadataKey { + key: k.clone(), + source, + })?; let val = MetadataValue::from_str(&v) - .map_err(|source| ClientError::InvalidMetadataValue { key: k, source })?; + .map_err(|source| GrpcRequestError::InvalidMetadataValue { key: k, source })?; request.metadata_mut().insert(key, val); } Ok(request) diff --git a/granc/src/core/codec.rs b/granc-core/src/grpc/codec.rs similarity index 100% rename from granc/src/core/codec.rs rename to granc-core/src/grpc/codec.rs diff --git a/granc-core/src/lib.rs b/granc-core/src/lib.rs new file mode 100644 index 0000000..92a52e6 --- /dev/null +++ b/granc-core/src/lib.rs @@ -0,0 +1,50 @@ +//! # Granc Core +//! +//! `granc-core` is the foundational library powering the Granc CLI. It provides a dynamic +//! gRPC client capable of interacting with any gRPC server without compile-time knowledge +//! of the Protobuf schema. +//! +//! ## Key Components +//! +//! * **[`GrancClient`]:** The main entry point. It orchestrates schema resolution (via reflection +//! or file descriptors) and dispatches requests to the generic gRPC transport. +//! * **[`DynamicRequest`] & [`DynamicResponse`]:** The primary data structures for I/O, allowing +//! callers to pass JSON data and receive JSON results. +//! +//! ## Internal clients +//! +//! We've decided to expose the core clients that we use internally to perform gRPC requests using JSON +//! and to interact with a server reflection service. +//! +//! * **[`GrpcClient`]:** A fully-featured dynamic gRPC client using a custom Json Codec. +//! * **[`ReflectionClient`]:** A gRPC Reflection client offering for now only the functionality that we need internally, +//! might be extended in the future and packaged as a separate crate if the community finds it useful. +//! +//! ## JsonCodec +//! +//! An implementation of `tonic::codec::Codec` that 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`. +//! +//! ## Feature Flags (Internal use only) +//! +//! * `gen-proto`: Enables support for generating reflection service bindings (internal use). +//! +//! ## Re-exports +//! +//! This crate re-exports `prost`, `prost-reflect`, and `tonic` to ensure that consumers +//! use compatible versions of these underlying dependencies. +//! +//! See the README.md for more details about usage. +pub mod client; +pub mod grpc; +pub mod reflection; + +// Re-exports +pub use prost; +pub use prost_reflect; +pub use tonic; + +/// Type alias for the standard boxed error used in generic bounds. +type BoxError = Box; diff --git a/granc-core/src/reflection.rs b/granc-core/src/reflection.rs new file mode 100644 index 0000000..ba351c0 --- /dev/null +++ b/granc-core/src/reflection.rs @@ -0,0 +1,8 @@ +//! # Server Reflection +//! +//! This module contains the logic necessary to interact with the gRPC Server Reflection Protocol. +//! +//! It enables the client to query a server for its own Protobuf schema at runtime, allowing +//! `granc` to function without pre-compiled descriptors. +pub mod client; +mod generated; diff --git a/granc-core/src/reflection/client.rs b/granc-core/src/reflection/client.rs new file mode 100644 index 0000000..60c57f3 --- /dev/null +++ b/granc-core/src/reflection/client.rs @@ -0,0 +1,223 @@ +//! # Reflection Client +//! +//! A client implementation for `grpc.reflection.v1`. +//! +//! This client is responsible for building a complete `FileDescriptorSet` by querying +//! a server that supports reflection. It handles the complexity of dependency management by inspecting +//! imports and recursively fetching missing files until the entire schema tree for a +//! requested symbol is resolved. +//! +//! ## References +//! +//! * [gRPC Server Reflection Protocol](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md)//! +use super::generated::reflection_v1::{ + ServerReflectionRequest, ServerReflectionResponse, + server_reflection_client::ServerReflectionClient, server_reflection_request::MessageRequest, + server_reflection_response::MessageResponse, +}; +use crate::BoxError; +use http_body::Body as HttpBody; +use prost::Message; +use prost_types::{FileDescriptorProto, FileDescriptorSet}; +use std::collections::{HashMap, HashSet}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tonic::transport::Channel; +use tonic::{Streaming, client::GrpcService}; + +#[derive(Debug, thiserror::Error)] +pub enum ReflectionResolveError { + #[error( + "Failed to start a stream request with the reflection server, reflection might not be supported: '{0}'" + )] + ServerStreamInitFailed(#[source] tonic::Status), + + #[error("The server stream returned an error status: '{0}'")] + ServerStreamFailure(#[source] tonic::Status), + + #[error("Reflection stream closed unexpectedly")] + StreamClosed, + + #[error("Internal error: Failed to send request to stream")] + SendFailed, + + #[error("Server returned reflection error code {code}: {message}")] + ServerError { code: i32, message: String }, + + #[error("Protocol error: Received unexpected response type: {0}")] + UnexpectedResponseType(String), + + #[error("Failed to decode FileDescriptorProto: {0}")] + DecodeError(#[from] prost::DecodeError), +} + +// The host defined in the reflection requests doesn't seem to be a mandatory field +// and there is no documentation about what it is about. +// So we won't enforce it from the user. +const EMPTY_HOST: &str = ""; + +/// A generic client for the gRPC Server Reflection Protocol. +pub struct ReflectionClient { + client: ServerReflectionClient, +} + +impl ReflectionClient +where + S: GrpcService, + S::Error: Into, + S::ResponseBody: HttpBody + Send + 'static, + ::Error: Into + Send, +{ + pub fn new(channel: S) -> Self { + let client = ServerReflectionClient::new(channel); + Self { client } + } + + /// Asks the reflection service for the file containing the requested symbol (e.g., `my.package.MyService`). + /// + /// **Recursive Resolution**: + /// - The server returns a `FileDescriptorProto`. + /// - The client inspects the imports (dependencies) of that file. + /// - It recursively requests any missing dependencies until the full `FileDescriptorSet` is built. + /// + /// # Returns + /// + /// * `Ok(fd_set)` - Successful reflection requests execution. + /// * `Err(ReflectionResolveError)` - Failed to request file descriptors to the reflection service. + pub async fn file_descriptor_set_by_symbol( + &mut self, + service_name: &str, + ) -> Result { + // Initialize Stream + let (tx, rx) = mpsc::channel(100); + + let mut response_stream = self + .client + .server_reflection_info(ReceiverStream::new(rx)) + .await + .map_err(ReflectionResolveError::ServerStreamInitFailed)? + .into_inner(); + + // Send Initial Request + let req = ServerReflectionRequest { + host: EMPTY_HOST.to_string(), + message_request: Some(MessageRequest::FileContainingSymbol( + service_name.to_string(), + )), + }; + + tx.send(req) + .await + .map_err(|_| ReflectionResolveError::SendFailed)?; + + // Fetch all transitive dependencies + let file_map = collect_descriptors(&mut response_stream, tx).await?; + + // Build Registry directly + let fd_set = FileDescriptorSet { + file: file_map.into_values().collect(), + }; + + Ok(fd_set) + } +} + +async fn collect_descriptors( + response_stream: &mut Streaming, + request_channel: mpsc::Sender, +) -> Result, ReflectionResolveError> { + let mut inflight = 1; + let mut collected_files = HashMap::new(); + let mut requested = HashSet::new(); + + while inflight > 0 { + let response = response_stream + .message() + .await + .map_err(ReflectionResolveError::ServerStreamFailure)? + .ok_or(ReflectionResolveError::StreamClosed)?; + + inflight -= 1; + + match response.message_response { + Some(MessageResponse::FileDescriptorResponse(res)) => { + let sent_count = process_descriptor_batch( + res.file_descriptor_proto, + &mut collected_files, + &mut requested, + &request_channel, + ) + .await?; + + inflight += sent_count; + } + Some(MessageResponse::ErrorResponse(e)) => { + return Err(ReflectionResolveError::ServerError { + message: e.error_message, + code: e.error_code, + }); + } + Some(other) => { + return Err(ReflectionResolveError::UnexpectedResponseType(format!( + "{:?}", + other + ))); + } + None => { + return Err(ReflectionResolveError::UnexpectedResponseType( + "Empty Message".into(), + )); + } + } + } + + Ok(collected_files) +} + +async fn process_descriptor_batch( + raw_protos: Vec>, + collected_files: &mut HashMap, + requested: &mut HashSet, + tx: &mpsc::Sender, +) -> Result { + let mut sent_count = 0; + + for raw in raw_protos { + let fd = FileDescriptorProto::decode(raw.as_ref())?; + + if let Some(name) = &fd.name + && !collected_files.contains_key(name) + { + sent_count += queue_dependencies(&fd, collected_files, requested, tx).await?; + + collected_files.insert(name.clone(), fd); + } + } + + Ok(sent_count) +} + +async fn queue_dependencies( + fd: &FileDescriptorProto, + collected_files: &HashMap, + requested: &mut HashSet, + tx: &mpsc::Sender, +) -> Result { + let mut count = 0; + + for dep in &fd.dependency { + if !collected_files.contains_key(dep) && requested.insert(dep.clone()) { + let req = ServerReflectionRequest { + host: EMPTY_HOST.to_string(), + message_request: Some(MessageRequest::FileByFilename(dep.clone())), + }; + + tx.send(req) + .await + .map_err(|_| ReflectionResolveError::SendFailed)?; + count += 1; + } + } + + Ok(count) +} diff --git a/granc/src/core/reflection/generated.rs b/granc-core/src/reflection/generated.rs similarity index 100% rename from granc/src/core/reflection/generated.rs rename to granc-core/src/reflection/generated.rs diff --git a/granc/src/core/reflection/generated/grpc.reflection.v1.rs b/granc-core/src/reflection/generated/grpc.reflection.v1.rs similarity index 100% rename from granc/src/core/reflection/generated/grpc.reflection.v1.rs rename to granc-core/src/reflection/generated/grpc.reflection.v1.rs diff --git a/granc/src/core/reflection/client/integration_test/dummy_service.rs b/granc-core/tests/dummy_echo_service_impl.rs similarity index 94% rename from granc/src/core/reflection/client/integration_test/dummy_service.rs rename to granc-core/tests/dummy_echo_service_impl.rs index cd5254f..6d8420c 100644 --- a/granc/src/core/reflection/client/integration_test/dummy_service.rs +++ b/granc-core/tests/dummy_echo_service_impl.rs @@ -1,7 +1,5 @@ -use echo_service::{ - EchoService, - pb::{EchoRequest, EchoResponse}, -}; +use echo_service::EchoService; +use echo_service::pb::{EchoRequest, EchoResponse}; use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status, Streaming}; diff --git a/granc/src/core/client/integration_test/echo_service_impl.rs b/granc-core/tests/echo_service_impl.rs similarity index 99% rename from granc/src/core/client/integration_test/echo_service_impl.rs rename to granc-core/tests/echo_service_impl.rs index 128d61c..99d3a55 100644 --- a/granc/src/core/client/integration_test/echo_service_impl.rs +++ b/granc-core/tests/echo_service_impl.rs @@ -1,6 +1,5 @@ use echo_service::EchoService; use echo_service::pb::{EchoRequest, EchoResponse}; - use futures_util::Stream; use std::pin::Pin; use tokio::sync::mpsc; diff --git a/granc-core/tests/granc_client_test.rs b/granc-core/tests/granc_client_test.rs new file mode 100644 index 0000000..8b1b39a --- /dev/null +++ b/granc-core/tests/granc_client_test.rs @@ -0,0 +1,128 @@ +use echo_service::EchoServiceServer; +use echo_service::FILE_DESCRIPTOR_SET; +use echo_service_impl::EchoServiceImpl; +use granc_core::client::{DynamicRequest, DynamicResponse, GrancClient}; + +mod echo_service_impl; + +#[tokio::test] +async fn test_unary() { + let payload = serde_json::json!({ "message": "hello" }); + + let request = DynamicRequest { + file_descriptor_set: Some(FILE_DESCRIPTOR_SET.to_vec()), + body: payload.clone(), + headers: vec![], + service: "echo.EchoService".to_string(), + method: "UnaryEcho".to_string(), + }; + + let mut client = GrancClient::new(EchoServiceServer::new(EchoServiceImpl)); + + let res = client.dynamic(request).await.unwrap(); + + match res { + DynamicResponse::Unary(Ok(value)) => assert_eq!(value, payload), + DynamicResponse::Unary(Err(_)) => { + panic!("Received error status for valid unary request") + } + _ => panic!("Received stream response for unary request"), + }; +} + +#[tokio::test] +async fn test_server_streaming() { + let payload = serde_json::json!({ "message": "stream" }); + + let request = DynamicRequest { + file_descriptor_set: Some(FILE_DESCRIPTOR_SET.to_vec()), + body: payload.clone(), + headers: vec![], + service: "echo.EchoService".to_string(), + method: "ServerStreamingEcho".to_string(), + }; + + let mut client = GrancClient::new(EchoServiceServer::new(EchoServiceImpl)); + + let res = client.dynamic(request).await.unwrap(); + + match res { + DynamicResponse::Streaming(Ok(elems)) => { + let results: Vec<_> = elems.into_iter().map(|r| r.unwrap()).collect(); + + assert_eq!(results.len(), 3); + assert_eq!(results[0]["message"], "stream - seq 0"); + assert_eq!(results[1]["message"], "stream - seq 1"); + assert_eq!(results[2]["message"], "stream - seq 2"); + } + DynamicResponse::Streaming(Err(_)) => { + panic!("Received error status for valid server streaming request") + } + _ => panic!("Received unary response for server streaming request"), + }; +} + +#[tokio::test] +async fn test_client_streaming() { + let payload = serde_json::json!([ + { "message": "A" }, + { "message": "B" }, + { "message": "C" } + ]); + + let request = DynamicRequest { + file_descriptor_set: Some(FILE_DESCRIPTOR_SET.to_vec()), + body: payload.clone(), + headers: vec![], + service: "echo.EchoService".to_string(), + method: "ClientStreamingEcho".to_string(), + }; + + let mut client = GrancClient::new(EchoServiceServer::new(EchoServiceImpl)); + + let res = client.dynamic(request).await.unwrap(); + + match res { + DynamicResponse::Unary(Ok(value)) => { + assert_eq!(value, serde_json::json!({"message": "ABC"})) + } + DynamicResponse::Unary(Err(_)) => { + panic!("Received error status for valid client stream request") + } + _ => panic!("Received stream response for client stream request"), + }; +} + +#[tokio::test] +async fn test_bidirectional_streaming() { + let payload = serde_json::json!([ + { "message": "Ping" }, + { "message": "Pong" } + ]); + + let request = DynamicRequest { + file_descriptor_set: Some(FILE_DESCRIPTOR_SET.to_vec()), + body: payload.clone(), + headers: vec![], + service: "echo.EchoService".to_string(), + method: "BidirectionalEcho".to_string(), + }; + + let mut client = GrancClient::new(EchoServiceServer::new(EchoServiceImpl)); + + let res = client.dynamic(request).await.unwrap(); + + match res { + DynamicResponse::Streaming(Ok(elems)) => { + let results: Vec<_> = elems.into_iter().map(|r| r.unwrap()).collect(); + + assert_eq!(results.len(), 2); + assert_eq!(results[0]["message"], "echo: Ping"); + assert_eq!(results[1]["message"], "echo: Pong"); + } + DynamicResponse::Streaming(Err(_)) => { + panic!("Received error status for valid bidirectional streaming request") + } + _ => panic!("Received unary response for bidirectional streaming request"), + }; +} diff --git a/granc-core/tests/reflection_client_test.rs b/granc-core/tests/reflection_client_test.rs new file mode 100644 index 0000000..fd933d1 --- /dev/null +++ b/granc-core/tests/reflection_client_test.rs @@ -0,0 +1,138 @@ +use dummy_echo_service_impl::DummyEchoService; +use echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET}; +use granc_core::reflection::client::{ReflectionClient, ReflectionResolveError}; +use prost_reflect::DescriptorPool; +use tonic::Code; +use tonic_reflection::server::v1::ServerReflectionServer; + +mod dummy_echo_service_impl; + +fn setup_reflection_client() +-> ReflectionClient> { + // Configure the Reflection Service using the descriptor set from echo-service + let reflection_service = tonic_reflection::server::Builder::configure() + .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET) + .build_v1() + .expect("Failed to setup Reflection Service"); + + ReflectionClient::new(reflection_service) +} + +#[tokio::test] +async fn test_reflection_client_fetches_service_file_descriptor() { + let mut client = setup_reflection_client(); + + let fd_set = client + .file_descriptor_set_by_symbol("echo.EchoService") + .await + .expect("Failed to fetch file descriptor set by symbol"); + + let pool = + DescriptorPool::from_file_descriptor_set(fd_set).expect("Failed to build descriptor pool"); + + let service = pool + .get_service_by_name("echo.EchoService") + .expect("Failed to find service in file descriptor"); + + assert!(service.methods().all(|f| f.input().name() == "EchoRequest")); + assert!( + service + .methods() + .all(|f| f.output().name() == "EchoResponse") + ); + + let unary_method = service.methods().find(|m| m.name() == "UnaryEcho").unwrap(); + + let client_streaming_method = service + .methods() + .find(|m| m.name() == "ClientStreamingEcho") + .unwrap(); + + let server_streaming_method = service + .methods() + .find(|m| m.name() == "ServerStreamingEcho") + .unwrap(); + + let bidirectional_method = service + .methods() + .find(|m| m.name() == "BidirectionalEcho") + .unwrap(); + + assert!( + !unary_method.is_client_streaming(), + "Unary should not be client streaming" + ); + assert!( + !unary_method.is_server_streaming(), + "Unary should not be server streaming" + ); + + // Assert Streaming Properties (Client Streaming only) + assert!( + client_streaming_method.is_client_streaming(), + "ClientStreaming MUST be client streaming" + ); + assert!( + !client_streaming_method.is_server_streaming(), + "ClientStreaming should not be server streaming" + ); + + assert!( + !server_streaming_method.is_client_streaming(), + "ServerStreaming should not be client streaming" + ); + assert!( + server_streaming_method.is_server_streaming(), + "ServerStreaming MUST be server streaming" + ); + + assert!( + bidirectional_method.is_client_streaming(), + "Bidirectional MUST be client streaming" + ); + + assert!( + bidirectional_method.is_server_streaming(), + "Bidirectional MUST be server streaming" + ); +} + +#[tokio::test] +async fn test_reflection_service_not_found_error() { + let mut client = setup_reflection_client(); + + let result: Result<_, _> = client + .file_descriptor_set_by_symbol("non.existent.Service") + .await; + + assert!(matches!( + result, + Err(ReflectionResolveError::ServerStreamFailure(status)) if status.code() == Code::NotFound + )); +} + +#[tokio::test] +async fn test_server_does_not_support_reflection() { + // Create a server that ONLY hosts the EchoService. + // This server does NOT have the Reflection service registered. + let server = EchoServiceServer::new(DummyEchoService); + let mut client = ReflectionClient::new(server); + + // The client will attempt to call `/grpc.reflection.v1.ServerReflection/ServerReflectionInfo` on this service. + let result = client + .file_descriptor_set_by_symbol("echo.EchoService") + .await; + + match result { + Err(ReflectionResolveError::ServerStreamInitFailed(status)) => { + assert_eq!( + status.code(), + tonic::Code::Unimplemented, + "Expected UNIMPLEMENTED status (service not found), but got: {:?}", + status + ); + } + Err(e) => panic!("Expected StreamInitFailed(Unimplemented), got: {:?}", e), + Ok(_) => panic!("Expected error, but got successful registry"), + } +} diff --git a/granc/CHANGELOG.md b/granc/CHANGELOG.md deleted file mode 100644 index 51e4dd0..0000000 --- a/granc/CHANGELOG.md +++ /dev/null @@ -1,47 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -## `granc` - [0.2.3](https://github.com/JasterV/granc/compare/granc-v0.2.2...granc-v0.2.3) - 2026-01-21 - -### Other - -- **Internal refactor**: Decouple ReflectionClient to possibly publish in a separate crate - -## `granc` - [0.2.2](https://github.com/JasterV/granc/compare/granc-v0.2.1...granc-v0.2.2) - 2026-01-21 - -### Other - -- Update README.md - -## `granc` - [0.2.1](https://github.com/JasterV/granc/compare/granc-v0.2.0...granc-v0.2.1) - 2026-01-21 - -### Other - -- Update README - -## `granc` - [0.2.0](https://github.com/JasterV/granc/compare/granc-v0.1.0...granc-v0.2.0) - 2026-01-21 - -### Added - -- **Automatic Reflection**: The tool now supports automatic reflection, trying to reach the reflection service in the server if the user doesn't provide a file descriptor binary ([#9](https://github.com/JasterV/granc/pull/9)) - -## `granc` - 0.1.0 2026-01-20 - -### Added - -- **Dynamic gRPC Client**: Implemented a CLI that performs gRPC calls without generating Rust code, bridging JSON payloads to Protobuf binary format at runtime. -- **Schema Loading**: Support for loading Protobuf schemas dynamically from binary `FileDescriptorSet` (`.bin` or `.pb`) files. -- **Full Streaming Support**: Automatic dispatch for all four gRPC access patterns based on the method descriptor: - - Unary (Single Request → Single Response) - - Server Streaming (Single Request → Stream) - - Client Streaming (Stream → Single Response) - - Bidirectional Streaming (Stream → Stream) -- **JSON Transcoding**: Custom `tonic::Codec` implementation (`JsonCodec`) to validate and transcode `serde_json::Value` to/from Protobuf bytes on the fly. -- **Metadata Support**: Ability to attach custom headers/metadata to requests via the `-H` / `--header` flag. -- **Input Validation**: Fast-fail validation that checks if the provided JSON structure is valid before making the network request. diff --git a/granc/Cargo.toml b/granc/Cargo.toml index 7d92f1d..7c155eb 100644 --- a/granc/Cargo.toml +++ b/granc/Cargo.toml @@ -1,48 +1,21 @@ [package] -authors = ["Victor Martínez Montané "] +authors = { workspace = true } categories = ["network-programming", "command-line-utilities"] description = "A dynamic gRPC CLI tool written in Rust (gRPC + Cranc, Crab in Catalan)" -edition = "2024" -homepage = "https://github.com/JasterV/granc" +edition = { workspace = true } +homepage = { workspace = true } keywords = ["cli", "command-line", "grpc", "grpcurl", "curl"] -license = "MIT OR Apache-2.0" +license = { workspace = true } name = "granc" publish = true -readme = "README.md" -repository = "https://github.com/JasterV/granc" -rust-version = "1.89" -version = "0.2.3" -default-run = "granc" - -[features] -gen-proto = ["dep:tonic-prost-build"] - -[[bin]] -name = "granc" -path = "src/main.rs" - -[[bin]] -name = "generate_reflection_service" -path = "bin/generate_reflection_service.rs" -required-features = ["gen-proto"] +readme = "../README.md" +repository = { workspace = true } +rust-version = { workspace = true } +version = { workspace = true } [dependencies] clap = { version = "4.5.54", features = ["derive"] } -futures-util = "0.3.31" -http = "1.4.0" -http-body = "1.0.1" -prost = "0.14.3" -prost-reflect = { version = "0.16.3", features = ["serde"] } -prost-types = "0.14.3" -serde_json = "1.0.149" -thiserror = "2.0.18" -tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros"] } -tokio-stream = "0.1.18" -tonic = "0.14.2" -tonic-prost = "0.14.2" -tonic-prost-build = { version = "0.14", optional = true } - -[dev-dependencies] -echo-service = { path = "../echo-service" } -tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] } -tonic-reflection = "0.14" +granc_core = { path = "../granc-core" } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tonic = { workspace = true } diff --git a/granc/README.md b/granc/README.md deleted file mode 100644 index 05ebd1b..0000000 --- a/granc/README.md +++ /dev/null @@ -1,171 +0,0 @@ -# Granc 🦀 - -[![granc on crates.io](https://img.shields.io/crates/v/granc)](https://crates.io/crates/granc) - -> ⚠️ **Status: Experimental** -> -> This project is currently in a **highly experimental phase**. It is a working prototype intended for testing and development purposes. APIs, command-line arguments, and internal logic are subject to breaking changes. Please use with caution. - -**Granc** (gRPC + Cranc, Crab in Catalan) is a lightweight, dynamic gRPC CLI tool written in Rust. - -It allows you to make gRPC calls to any server using simple JSON payloads, without needing to compile the specific Protobuf files into the client. By loading a `FileDescriptorSet` at runtime, granc acts as a bridge between human-readable JSON and binary Protobuf wire format. - -It is heavily inspired by tools like `grpcurl` but built to leverage the safety and performance of the Rust ecosystem (Tonic + Prost). - -## 🚀 Features - -* **Dynamic Encoding/Decoding**: Transcodes JSON to Protobuf (and vice versa) on the fly using `prost-reflect`. -* **Smart Dispatch**: Automatically detects if a call is Unary, Server Streaming, Client Streaming, or Bidirectional based on the descriptor. -* **Server Reflection**: Can fetch schemas directly from the server, removing the need to pass a local file descriptor set file (`.bin` or `.pb`). -* **Metadata Support**: Easily attach custom headers (authorization, tracing) to your requests. -* **Fast Fail Validation**: Validates your JSON *before* hitting the network. -* **Zero Compilation Dependencies**: Does not require generating Rust code for your protos. Just point to a descriptor file. -* **Tonic 0.14**: Built on the latest stable Rust gRPC stack. - -## 📦 Installation - -### From Crates.io - -```bash -cargo install granc -``` - -### From Source - -Ensure you have Rust and Cargo installed. - -```bash -git clone https://github.com/JasterV/granc -cd granc -cargo install --path . -``` - -## 🛠️ Prerequisites - -Granc needs to know the schema of the service you are calling. It can obtain this in two ways: - -1. **Automatic Server Reflection**: If the server has [Server Reflection](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md) enabled, Granc can download the schema automatically. -2. **Local Descriptor File**: You can provide a binary `FileDescriptorSet` (`.bin`) generated by `protoc`. - -### Generating Descriptors (Optional) - -If your server does not support reflection, you must generate a descriptor file: - -```bash -# Generate descriptor.bin including all imports -protoc \ - --include_imports \ - --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. - -## 📖 Usage - -**Syntax:** - -```bash -granc [OPTIONS] -``` - -### Arguments - -| Argument | Description | Required | -| --- | --- | --- | -| `` | Server address (e.g., `http://[::1]:50051`). | **Yes** | -| `` | Fully qualified method name (e.g., `my.package.Service/Method`). | **Yes** | - -### Options - -| Flag | Short | Description | Required | -| --- | --- | --- | --- | -| `--proto-set` | | Path to the binary FileDescriptorSet (`.bin`). | **No** | -| `--body` | | The request body in JSON format. | **Yes** | -| `--header` | `-H` | Custom header `key:value`. Can be used multiple times. | No | - -### Automatic Server Reflection - -If you omit the `--proto-set` flag, Granc will automatically attempt to connect to the server's reflection service to download the necessary schemas. - -```bash -# Using Reflection (no descriptor file needed) -granc \ - --body '{"name": "Ferris"}' \ - http://localhost:50051 \ - helloworld.Greeter/SayHello -``` - -This requires the server to have the [`grpc.reflection.v1`](https://github.com/grpc/grpc-proto/blob/master/grpc/reflection/v1/reflection.proto) service enabled. - -### JSON Body Format - -* **Unary / Server Streaming**: Provide a single JSON object `{ ... }`. -* **Client / Bidirectional Streaming**: Provide a JSON array of objects `[ { ... }, { ... } ]`. - -### Examples - -**1. Unary Call (using local descriptor)** - -```bash -granc \ - --proto-set ./descriptor.bin \ - --body '{"name": "Ferris"}' \ - http://localhost:50051 \ - helloworld.Greeter/SayHello -``` - -**2. Bidirectional Streaming (Chat)** - -```bash -granc \ - --body '[{"text": "Hello"}, {"text": "How are you?"}]' \ - -H "authorization: Bearer token123" \ - http://localhost:50051 \ - chat.ChatService/StreamMessages -``` - -## 🔮 Roadmap - -* **Interactive Mode**: A REPL for streaming requests interactively. -* **Pretty Printing**: Enhanced colored output for JSON responses. -* **TLS Support**: Configurable root certificates and client identity. - -## ⚠️ Common Errors - -**1. `Service 'x' not found**` - -* **Cause:** The service name in the command does not match the package defined in your proto file. -* **Fix:** Check your `.proto` file. If it has `package my.app;` and `service API {}`, the full name is `my.app.API`. - -**2. `Method 'y' not found in service 'x'**` - -* **Cause:** Typo in the method name or the method doesn't exist. -* **Fix:** Ensure case sensitivity matches (e.g., `GetUser` vs `getUser`). - -**3. `h2 protocol error**` - -* **Cause:** This often occurs when the JSON payload fails to encode *after* the connection has already been established, or the server rejected the stream structure. -* **Fix:** Double-check your JSON payload against the Protobuf schema. - -## 🤝 Contributing - -Contributions are welcome! Please run the Makefile checks before submitting a PR: - -```bash -cargo make ci # Checks formatting, lints, and runs tests -``` - -## 📄 License - -Licensed under either of: - -* Apache License, Version 2.0 ([LICENSE-APACHE](http://www.apache.org/licenses/LICENSE-2.0)) -* MIT license ([LICENSE-MIT](http://opensource.org/licenses/MIT)) - -at your option. - -### Contribution - -Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. diff --git a/granc/src/cli.rs b/granc/src/cli.rs index dfffd82..cf6123f 100644 --- a/granc/src/cli.rs +++ b/granc/src/cli.rs @@ -1,19 +1,16 @@ -//! # Command Line Interface Definition +//! # CLI //! -//! This module utilizes `clap` to define the command-line arguments and flags -//! accepted by the application. It acts as the user-facing entry point, responsible for: -//! -//! 1. **Parsing**: extracting and ensuring each argument and flag can be parsed to the target Rust types. -//! 2. **Conversion**: transforming the raw arguments into the `crate::core::Input` struct used by the core logic. +//! 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`); use clap::Parser; -use std::path::PathBuf; +use granc_core::client::DynamicRequest; #[derive(Parser)] #[command(name = "granc", version, about = "Dynamic gRPC CLI")] pub struct Cli { - #[arg(long, help = "Path to the descriptor set (.bin)")] - pub proto_set: Option, + #[arg(long, help = "Path to the descriptor set (.bin)", value_parser = parse_file_descriptor_set)] + pub file_descriptor_set: Option>, #[arg(long, help = "JSON body (Object for Unary, Array for Streaming)", value_parser = parse_body)] pub body: serde_json::Value, @@ -28,22 +25,28 @@ pub struct Cli { pub endpoint: (String, String), } -impl From for crate::core::Input { +impl From for DynamicRequest { /// Converts the raw CLI arguments into the internal `Input` representation. fn from(value: Cli) -> Self { let (service, method) = value.endpoint; Self { - proto_set: value.proto_set, + file_descriptor_set: value.file_descriptor_set, body: value.body, headers: value.headers, - url: value.url, service, method, } } } +fn parse_file_descriptor_set(path: &str) -> Result, String> { + let path = path.trim(); + + std::fs::read(path) + .map_err(|err| format!("Failed to read file descriptor set at path '{path}': '{err}'")) +} + 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'",) diff --git a/granc/src/core.rs b/granc/src/core.rs deleted file mode 100644 index b2b3106..0000000 --- a/granc/src/core.rs +++ /dev/null @@ -1,162 +0,0 @@ -//! # Core Orchestration Layer -//! -//! This module is the "brain" of the application. It orchestrates the flow of a single execution: -//! -//! 1. **Schema Resolution**: It determines whether to load descriptors from a local file -//! or fetch them dynamically from the server via Reflection. -//! 2. **Method Lookup**: It locates the specific `MethodDescriptor` within the given descriptor registry. -//! 3. **Dispatch**: It initializes the `GrpcClient` and selects the correct handler -//! (Unary, ServerStreaming, etc.) based on the grpc method type. -mod client; -mod codec; -mod reflection; - -use client::GrpcClient; -use futures_util::{Stream, StreamExt}; -use prost_reflect::MethodDescriptor; -use reflection::{DescriptorRegistry, ReflectionClient}; -use std::path::PathBuf; - -use crate::core::{ - client::ClientError, - reflection::{ - client::{ReflectionConnectError, ReflectionResolveError}, - registry::DescriptorError, - }, -}; - -/// Type alias for the standard boxed error used in generic bounds. -pub type BoxError = Box; - -/// Request parameters (URL, Body, Headers... etc.). -pub struct Input { - pub proto_set: Option, - pub body: serde_json::Value, - pub headers: Vec<(String, String)>, - pub url: String, - pub service: String, - pub method: String, -} - -/// A unified enum representing the result, whether it's a single value or a stream -pub enum Output { - Unary(Result), - Streaming(Result>, tonic::Status>), -} - -/// Defines all the possible reasons the execution could fail for. -#[derive(Debug, thiserror::Error)] -pub enum CoreError { - #[error("Descriptor registry error: {0}")] - Registry(#[from] DescriptorError), - - #[error("Reflection connection failed: {0}")] - ReflectionConnect(#[from] ReflectionConnectError), - - #[error("Reflection resolution failed: {0}")] - ReflectionResolve(#[from] ReflectionResolveError), - - #[error("gRPC client error: {0}")] - Client(#[from] ClientError), - - #[error("Invalid input: {0}")] - InvalidInput(String), -} - -/// Executes the gRPC CLI logic. -/// -/// This function handles the high-level workflow: loading the descriptor registry either locally or using server reflection, -/// connecting to the server, and dispatching the request to the appropriate streaming handler. -pub async fn run(input: Input) -> Result { - let registry = match input.proto_set { - Some(path) => DescriptorRegistry::from_file(path)?, - // If no proto-set file is passed, we'll try to reach the server reflection service - None => { - let mut client = ReflectionClient::connect(input.url.clone()).await?; - let fd_set = client.file_descriptor_set_by_symbol(&input.service).await?; - DescriptorRegistry::from_file_descriptor_set(fd_set)? - } - }; - - let method = registry.get_method_descriptor(&input.service, &input.method)?; - - let client = GrpcClient::connect(&input.url).await?; - - println!("Calling {}/{}...", input.service, input.method); - - match (method.is_client_streaming(), method.is_server_streaming()) { - (false, false) => handle_unary(client, method, input.body, input.headers).await, - (false, true) => handle_server_stream(client, method, input.body, input.headers).await, - (true, false) => handle_client_stream(client, method, input.body, input.headers).await, - (true, true) => { - handle_bidirectional_stream(client, method, input.body, input.headers).await - } - } -} - -// --- Handlers --- - -async fn handle_unary( - client: GrpcClient, - method: MethodDescriptor, - body: serde_json::Value, - headers: Vec<(String, String)>, -) -> Result { - let result = client.unary(method, body, headers).await?; - Ok(Output::Unary(result)) -} - -async fn handle_server_stream( - client: GrpcClient, - method: MethodDescriptor, - body: serde_json::Value, - headers: Vec<(String, String)>, -) -> Result { - match client.server_streaming(method, body, headers).await? { - Ok(stream) => Ok(Output::Streaming(Ok(stream.collect().await))), - Err(status) => Ok(Output::Streaming(Err(status))), - } -} - -async fn handle_client_stream( - client: GrpcClient, - method: MethodDescriptor, - body: serde_json::Value, - headers: Vec<(String, String)>, -) -> Result { - let input_stream = json_array_to_stream(body)?; - - let result = client - .client_streaming(method, input_stream, headers) - .await?; - - Ok(Output::Unary(result)) -} - -async fn handle_bidirectional_stream( - client: GrpcClient, - method: MethodDescriptor, - body: serde_json::Value, - headers: Vec<(String, String)>, -) -> Result { - let input_stream = json_array_to_stream(body)?; - - match client - .bidirectional_streaming(method, input_stream, headers) - .await? - { - Ok(stream) => Ok(Output::Streaming(Ok(stream.collect().await))), - Err(status) => Ok(Output::Streaming(Err(status))), - } -} - -fn json_array_to_stream( - json: serde_json::Value, -) -> Result + Send + 'static, CoreError> { - match json { - serde_json::Value::Array(items) => Ok(tokio_stream::iter(items)), - _ => Err(CoreError::InvalidInput( - "Client streaming requires a JSON Array body".to_string(), - )), - } -} diff --git a/granc/src/core/client/integration_test.rs b/granc/src/core/client/integration_test.rs deleted file mode 100644 index 07dd761..0000000 --- a/granc/src/core/client/integration_test.rs +++ /dev/null @@ -1,118 +0,0 @@ -use crate::core::client::GrpcClient; -use crate::core::reflection::DescriptorRegistry; -use echo_service::EchoServiceServer; -use echo_service::FILE_DESCRIPTOR_SET; -use echo_service_impl::EchoServiceImpl; -use tokio_stream::StreamExt; - -mod echo_service_impl; - -#[tokio::test] -async fn test_unary() { - let registry = DescriptorRegistry::from_bytes(FILE_DESCRIPTOR_SET).unwrap(); - - let method = registry - .get_method_descriptor("echo.EchoService", "UnaryEcho") - .unwrap(); - - let client = GrpcClient { - service: EchoServiceServer::new(EchoServiceImpl), - }; - - let payload = serde_json::json!({ "message": "hello" }); - - let res = client - .unary(method, payload, vec![]) - .await - .unwrap() - .unwrap(); - - assert_eq!(res["message"], "hello"); -} - -#[tokio::test] -async fn test_server_streaming() { - let registry = DescriptorRegistry::from_bytes(FILE_DESCRIPTOR_SET).unwrap(); - - let method = registry - .get_method_descriptor("echo.EchoService", "ServerStreamingEcho") - .unwrap(); - - let client = GrpcClient { - service: EchoServiceServer::new(EchoServiceImpl), - }; - - let payload = serde_json::json!({ "message": "stream" }); - - let stream = client - .server_streaming(method, payload, vec![]) - .await - .unwrap() - .unwrap(); - - let results: Vec<_> = stream.map(|r| r.unwrap()).collect().await; - - assert_eq!(results.len(), 3); - assert_eq!(results[0]["message"], "stream - seq 0"); - assert_eq!(results[1]["message"], "stream - seq 1"); - assert_eq!(results[2]["message"], "stream - seq 2"); -} - -#[tokio::test] -async fn test_client_streaming() { - let registry = DescriptorRegistry::from_bytes(FILE_DESCRIPTOR_SET).unwrap(); - let method = registry - .get_method_descriptor("echo.EchoService", "ClientStreamingEcho") - .unwrap(); - - let client = GrpcClient { - service: EchoServiceServer::new(EchoServiceImpl), - }; - - let payload = serde_json::json!([ - { "message": "A" }, - { "message": "B" }, - { "message": "C" } - ]); - - let stream_source = tokio_stream::iter(payload.as_array().unwrap().clone()); - - let res = client - .client_streaming(method, stream_source, vec![]) - .await - .unwrap() - .unwrap(); - - assert_eq!(res["message"], "ABC"); -} - -#[tokio::test] -async fn test_bidirectional_streaming() { - let registry = DescriptorRegistry::from_bytes(FILE_DESCRIPTOR_SET).unwrap(); - let method = registry - .get_method_descriptor("echo.EchoService", "BidirectionalEcho") - .unwrap(); - - let client = GrpcClient { - service: EchoServiceServer::new(EchoServiceImpl), - }; - - let payload = serde_json::json!([ - { "message": "Ping" }, - { "message": "Pong" } - ]); - - let stream_source = tokio_stream::iter(payload.as_array().unwrap().clone()); - - let response_stream = client - .bidirectional_streaming(method, stream_source, vec![]) - .await - .unwrap() - .unwrap(); - - let results: Vec<_> = response_stream.map(|r| r.unwrap()).collect().await; - - assert_eq!(results.len(), 2); - assert_eq!(results[0]["message"], "echo: Ping"); - assert_eq!(results[1]["message"], "echo: Pong"); -} diff --git a/granc/src/core/reflection.rs b/granc/src/core/reflection.rs deleted file mode 100644 index 3c9152b..0000000 --- a/granc/src/core/reflection.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod client; -mod generated; -pub mod registry; - -pub use client::ReflectionClient; -pub use registry::DescriptorRegistry; diff --git a/granc/src/core/reflection/client.rs b/granc/src/core/reflection/client.rs deleted file mode 100644 index 9d0f8de..0000000 --- a/granc/src/core/reflection/client.rs +++ /dev/null @@ -1,251 +0,0 @@ -//! # gRPC Server Reflection Client -//! -//! This module implements a client for `grpc.reflection.v1`. It enables `granc` to function -//! without a local descriptor file by asking the server for its own schema. -//! -//! ## The Resolution Process -//! -//! 1. **Connect**: Opens a stream to the reflection endpoint. -//! 2. **Request Symbol**: Asks for the file containing the requested service (e.g., `my.package.MyService`). -//! 3. **Recursive Resolution**: -//! - The server returns a `FileDescriptorProto`. -//! - The client inspects the imports (dependencies) of that file. -//! - It recursively requests any missing dependencies until the full schema tree is built. -//! 4. **Build Registry**: Returns a fully populated `DescriptorRegistry`. -//! -use super::generated::reflection_v1::{ - ServerReflectionRequest, ServerReflectionResponse, - server_reflection_client::ServerReflectionClient, server_reflection_request::MessageRequest, - server_reflection_response::MessageResponse, -}; -use crate::core::BoxError; -use http_body::Body as HttpBody; -use prost::Message; -use prost_types::{FileDescriptorProto, FileDescriptorSet}; -use std::collections::{HashMap, HashSet}; -use tokio::sync::mpsc; -use tokio_stream::wrappers::ReceiverStream; -use tonic::transport::{Channel, Endpoint}; -use tonic::{Streaming, client::GrpcService}; - -#[cfg(test)] -mod integration_test; - -#[derive(Debug, thiserror::Error)] -pub enum ReflectionConnectError { - #[error("Invalid URL '{0}': {1}")] - InvalidUrl(String, #[source] tonic::transport::Error), - #[error("Failed to connect to '{0}': {1}")] - ConnectionFailed(String, #[source] tonic::transport::Error), -} - -#[derive(Debug, thiserror::Error)] -pub enum ReflectionResolveError { - #[error( - "Failed to start a stream request with the reflection server, reflection might not be supported: '{0}'" - )] - ServerStreamInitFailed(#[source] tonic::Status), - - #[error("The server stream returned an error status: '{0}'")] - ServerStreamFailure(#[source] tonic::Status), - - #[error("Reflection stream closed unexpectedly")] - StreamClosed, - - #[error("Internal error: Failed to send request to stream")] - SendFailed, - - #[error("Server returned reflection error code {code}: {message}")] - ServerError { code: i32, message: String }, - - #[error("Protocol error: Received unexpected response type: {0}")] - UnexpectedResponseType(String), - - #[error("Failed to decode FileDescriptorProto: {0}")] - DecodeError(#[from] prost::DecodeError), -} - -/// A generic client for the gRPC Server Reflection Protocol. -pub struct ReflectionClient { - client: ServerReflectionClient, - base_url: String, -} - -/// Implementation for the standard network client. -impl ReflectionClient { - pub async fn connect(base_url: String) -> Result { - let endpoint = Endpoint::new(base_url.clone()) - .map_err(|e| ReflectionConnectError::InvalidUrl(base_url.clone(), e))?; - - let channel = endpoint - .connect() - .await - .map_err(|e| ReflectionConnectError::ConnectionFailed(base_url.clone(), e))?; - - let client = ServerReflectionClient::new(channel); - - Ok(Self { client, base_url }) - } -} - -impl ReflectionClient -where - T: GrpcService, - T::Error: Into, - T::ResponseBody: HttpBody + Send + 'static, - ::Error: Into + Send, -{ - /// Asks the reflection service for the file containing the requested symbol (e.g., `my.package.MyService`). - /// - /// **Recursive Resolution**: - /// - The server returns a `FileDescriptorProto`. - /// - The client inspects the imports (dependencies) of that file. - /// - It recursively requests any missing dependencies until the full `FileDescriptorSet` is built. - /// - /// # Returns - /// - /// * `Ok(fd_set)` - Successful reflection requests execution. - /// * `Err(ReflectionResolveError)` - Failed to request file descriptors to the reflection service. - pub async fn file_descriptor_set_by_symbol( - &mut self, - service_name: &str, - ) -> Result { - // Initialize Stream - let (tx, rx) = mpsc::channel(100); - - let mut response_stream = self - .client - .server_reflection_info(ReceiverStream::new(rx)) - .await - .map_err(ReflectionResolveError::ServerStreamInitFailed)? - .into_inner(); - - // Send Initial Request - let req = ServerReflectionRequest { - host: self.base_url.clone(), - message_request: Some(MessageRequest::FileContainingSymbol( - service_name.to_string(), - )), - }; - - tx.send(req) - .await - .map_err(|_| ReflectionResolveError::SendFailed)?; - - // Fetch all transitive dependencies - let file_map = self.collect_descriptors(&mut response_stream, tx).await?; - - // Build Registry directly - let fd_set = FileDescriptorSet { - file: file_map.into_values().collect(), - }; - - Ok(fd_set) - } - - async fn collect_descriptors( - &self, - response_stream: &mut Streaming, - request_channel: mpsc::Sender, - ) -> Result, ReflectionResolveError> { - let mut inflight = 1; - let mut collected_files = HashMap::new(); - let mut requested = HashSet::new(); - - while inflight > 0 { - let response = response_stream - .message() - .await - .map_err(ReflectionResolveError::ServerStreamFailure)? - .ok_or(ReflectionResolveError::StreamClosed)?; - - inflight -= 1; - - match response.message_response { - Some(MessageResponse::FileDescriptorResponse(res)) => { - let sent_count = self - .process_descriptor_batch( - res.file_descriptor_proto, - &mut collected_files, - &mut requested, - &request_channel, - ) - .await?; - - inflight += sent_count; - } - Some(MessageResponse::ErrorResponse(e)) => { - return Err(ReflectionResolveError::ServerError { - message: e.error_message, - code: e.error_code, - }); - } - Some(other) => { - return Err(ReflectionResolveError::UnexpectedResponseType(format!( - "{:?}", - other - ))); - } - None => { - return Err(ReflectionResolveError::UnexpectedResponseType( - "Empty Message".into(), - )); - } - } - } - - Ok(collected_files) - } - - async fn process_descriptor_batch( - &self, - raw_protos: Vec>, - collected_files: &mut HashMap, - requested: &mut HashSet, - tx: &mpsc::Sender, - ) -> Result { - let mut sent_count = 0; - - for raw in raw_protos { - let fd = FileDescriptorProto::decode(raw.as_ref())?; - - if let Some(name) = &fd.name - && !collected_files.contains_key(name) - { - sent_count += self - .queue_dependencies(&fd, collected_files, requested, tx) - .await?; - - collected_files.insert(name.clone(), fd); - } - } - - Ok(sent_count) - } - - async fn queue_dependencies( - &self, - fd: &FileDescriptorProto, - collected_files: &HashMap, - requested: &mut HashSet, - tx: &mpsc::Sender, - ) -> Result { - let mut count = 0; - - for dep in &fd.dependency { - if !collected_files.contains_key(dep) && requested.insert(dep.clone()) { - let req = ServerReflectionRequest { - host: self.base_url.clone(), - message_request: Some(MessageRequest::FileByFilename(dep.clone())), - }; - - tx.send(req) - .await - .map_err(|_| ReflectionResolveError::SendFailed)?; - count += 1; - } - } - - Ok(count) - } -} diff --git a/granc/src/core/reflection/client/integration_test.rs b/granc/src/core/reflection/client/integration_test.rs deleted file mode 100644 index 03aa461..0000000 --- a/granc/src/core/reflection/client/integration_test.rs +++ /dev/null @@ -1,192 +0,0 @@ -use crate::core::reflection::{ - DescriptorRegistry, - client::{ - ReflectionClient, ReflectionResolveError, integration_test::dummy_service::DummyEchoService, - }, - generated::reflection_v1::server_reflection_client::ServerReflectionClient, -}; -use echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET}; -use tonic::Code; -use tonic_reflection::server::v1::ServerReflectionServer; - -mod dummy_service; - -fn setup_reflection_client() --> ReflectionClient> { - // Configure the Reflection Service using the descriptor set from echo-service - let reflection_service = tonic_reflection::server::Builder::configure() - .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET) - .build_v1() - .expect("Failed to setup Reflection Service"); - - ReflectionClient { - client: ServerReflectionClient::new(reflection_service), - base_url: "http://localhost".to_string(), - } -} - -#[tokio::test] -async fn test_reflection_client_fetches_unary_echo() { - let mut client = setup_reflection_client(); - - let fd_set = client - .file_descriptor_set_by_symbol("echo.EchoService") - .await - .expect("Failed to fetch file descriptor set by symbol"); - - let registry = DescriptorRegistry::from_file_descriptor_set(fd_set) - .expect("Failed to build descriptor registry"); - - let method = registry - .get_method_descriptor("echo.EchoService", "UnaryEcho") - .expect("Method UnaryEcho not found"); - - // Assert Types - assert_eq!(method.input().name(), "EchoRequest"); - assert_eq!(method.output().name(), "EchoResponse"); - - // Assert Streaming Properties (Unary = No Streaming) - assert!( - !method.is_client_streaming(), - "Unary should not be client streaming" - ); - assert!( - !method.is_server_streaming(), - "Unary should not be server streaming" - ); -} - -#[tokio::test] -async fn test_reflection_client_fetches_server_streaming_echo() { - let mut client = setup_reflection_client(); - - let fd_set = client - .file_descriptor_set_by_symbol("echo.EchoService") - .await - .expect("Failed to fetch file descriptor set by symbol"); - - let registry = DescriptorRegistry::from_file_descriptor_set(fd_set) - .expect("Failed to build descriptor registry"); - - let method = registry - .get_method_descriptor("echo.EchoService", "ServerStreamingEcho") - .expect("Method ServerStreamingEcho not found"); - - // Assert Types - assert_eq!(method.input().name(), "EchoRequest"); - assert_eq!(method.output().name(), "EchoResponse"); - - // Assert Streaming Properties (Server Streaming only) - assert!( - !method.is_client_streaming(), - "ServerStreaming should not be client streaming" - ); - assert!( - method.is_server_streaming(), - "ServerStreaming MUST be server streaming" - ); -} - -#[tokio::test] -async fn test_reflection_client_fetches_client_streaming_echo() { - let mut client = setup_reflection_client(); - - let fd_set = client - .file_descriptor_set_by_symbol("echo.EchoService") - .await - .expect("Failed to fetch file descriptor set by symbol"); - - let registry = DescriptorRegistry::from_file_descriptor_set(fd_set) - .expect("Failed to build descriptor registry"); - - let method = registry - .get_method_descriptor("echo.EchoService", "ClientStreamingEcho") - .expect("Method ClientStreamingEcho not found"); - - // Assert Types - assert_eq!(method.input().name(), "EchoRequest"); - assert_eq!(method.output().name(), "EchoResponse"); - - // Assert Streaming Properties (Client Streaming only) - assert!( - method.is_client_streaming(), - "ClientStreaming MUST be client streaming" - ); - assert!( - !method.is_server_streaming(), - "ClientStreaming should not be server streaming" - ); -} - -#[tokio::test] -async fn test_reflection_client_fetches_bidirectional_echo() { - let mut client = setup_reflection_client(); - - let fd_set = client - .file_descriptor_set_by_symbol("echo.EchoService") - .await - .expect("Failed to fetch file descriptor set by symbol"); - - let registry = DescriptorRegistry::from_file_descriptor_set(fd_set) - .expect("Failed to build descriptor registry"); - - let method = registry - .get_method_descriptor("echo.EchoService", "BidirectionalEcho") - .expect("Method BidirectionalEcho not found"); - - assert_eq!(method.input().name(), "EchoRequest"); - assert_eq!(method.output().name(), "EchoResponse"); - - assert!( - method.is_client_streaming(), - "Bidirectional MUST be client streaming" - ); - assert!( - method.is_server_streaming(), - "Bidirectional MUST be server streaming" - ); -} - -#[tokio::test] -async fn test_reflection_service_not_found_error() { - let mut client = setup_reflection_client(); - - let result: Result<_, _> = client - .file_descriptor_set_by_symbol("non.existent.Service") - .await; - - assert!(matches!( - result, - Err(crate::core::reflection::client::ReflectionResolveError::ServerStreamFailure(status)) if status.code() == Code::NotFound - )); -} - -#[tokio::test] -async fn test_server_does_not_support_reflection() { - // Create a server that ONLY hosts the EchoService. - // This server does NOT have the Reflection service registered. - let server = EchoServiceServer::new(DummyEchoService); - - let mut client = ReflectionClient { - client: ServerReflectionClient::new(server), - base_url: "http://localhost".to_string(), - }; - - // The client will attempt to call `/grpc.reflection.v1.ServerReflection/ServerReflectionInfo` on this service. - let result = client - .file_descriptor_set_by_symbol("echo.EchoService") - .await; - - match result { - Err(ReflectionResolveError::ServerStreamInitFailed(status)) => { - assert_eq!( - status.code(), - tonic::Code::Unimplemented, - "Expected UNIMPLEMENTED status (service not found), but got: {:?}", - status - ); - } - Err(e) => panic!("Expected StreamInitFailed(Unimplemented), got: {:?}", e), - Ok(_) => panic!("Expected error, but got successful registry"), - } -} diff --git a/granc/src/core/reflection/registry.rs b/granc/src/core/reflection/registry.rs deleted file mode 100644 index a5e0c16..0000000 --- a/granc/src/core/reflection/registry.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! # Descriptor Registry -//! -//! This module manages the `prost_reflect::DescriptorPool`. -//! -//! The registry can be populated in two ways: -//! -//! 1. **From File**: Loading a binary `.bin` or `.pb` file (usually generated by `protoc`). -//! 2. **From Memory**: Constructed dynamically after fetching schemas via Server Reflection. -use prost_reflect::{DescriptorPool, MethodDescriptor}; -use prost_types::FileDescriptorSet; -use std::path::Path; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum DescriptorError { - #[error("Failed to read descriptor file: {0}")] - Io(#[from] std::io::Error), - #[error("Failed to decode descriptor set: {0}")] - Decode(#[from] prost_reflect::DescriptorError), - #[error("Service '{0}' not found")] - ServiceNotFound(String), - #[error("Method '{0}' not found")] - MethodNotFound(String), -} - -/// A registry that holds loaded Protobuf definitions and allows looking up -/// services and methods by name. -#[derive(Debug)] -pub struct DescriptorRegistry { - pool: DescriptorPool, -} - -impl DescriptorRegistry { - /// Decodes a FileDescriptorSet directly from a byte slice. - /// Useful for tests or embedded descriptors. - #[cfg(test)] - pub fn from_bytes(bytes: &[u8]) -> Result { - let pool = DescriptorPool::decode(bytes)?; - Ok(Self { pool }) - } - - /// Creates a registry from a `FileDescriptorSet` (e.g., from Server Reflection). - pub fn from_file_descriptor_set(set: FileDescriptorSet) -> Result { - let pool = DescriptorPool::from_file_descriptor_set(set)?; - Ok(Self { pool }) - } - - /// Creates a registry by reading a `.bin` or `.pb` file from disk. - pub fn from_file(path: impl AsRef) -> Result { - let bytes = std::fs::read(path)?; - let pool = DescriptorPool::decode(bytes.as_slice())?; - Ok(Self { pool }) - } - - /// Resolves a service and method (e.g., "my.package.MyService", "MyMethod") into a MethodDescriptor. - pub fn get_method_descriptor( - &self, - service_name: &str, - method_name: &str, - ) -> Result { - let service = self - .pool - .get_service_by_name(service_name) - .ok_or_else(|| DescriptorError::ServiceNotFound(service_name.to_string()))?; - - service - .methods() - .find(|m| m.name() == method_name) - .ok_or_else(|| DescriptorError::MethodNotFound(method_name.to_string())) - } -} diff --git a/granc/src/main.rs b/granc/src/main.rs index 4a972f5..eaee61a 100644 --- a/granc/src/main.rs +++ b/granc/src/main.rs @@ -1,23 +1,36 @@ -#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] +//! # Granc CLI Entry Point +//! +//! 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`. +//! 4. **Presentation**: Formats and prints the resulting JSON or error status to standard output/error. mod cli; -mod core; use clap::Parser; use cli::Cli; +use granc_core::client::{DynamicRequest, DynamicResponse, GrancClient}; use std::process; -use crate::core::Output; - #[tokio::main] async fn main() { let args = Cli::parse(); - match core::run(core::Input::from(args)).await { - Ok(Output::Unary(Ok(value))) => print_json(&value), - Ok(Output::Unary(Err(status))) => print_status(&status), - Ok(Output::Streaming(Ok(values))) => print_stream(&values), - Ok(Output::Streaming(Err(status))) => print_status(&status), + let mut client = match GrancClient::connect(&args.url).await { + Ok(client) => client, + Err(err) => { + eprintln!("Error: {err}"); + process::exit(1); + } + }; + + match client.dynamic(DynamicRequest::from(args)).await { + Ok(DynamicResponse::Unary(Ok(value))) => print_json(&value), + Ok(DynamicResponse::Unary(Err(status))) => print_status(&status), + Ok(DynamicResponse::Streaming(Ok(values))) => print_stream(&values), + Ok(DynamicResponse::Streaming(Err(status))) => print_status(&status), Err(err) => { eprintln!("Error: {err}"); process::exit(1); diff --git a/release-plz.toml b/release-plz.toml index bcde40c..8a8a595 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -1,6 +1,6 @@ [workspace] # set the path of all the crates to the changelog to the root of the repository -changelog_path = "./granc/CHANGELOG.md" +changelog_path = "CHANGELOG.md" pr_draft = true pr_labels = ["release"] pr_branch_prefix = "release-"