mirror of
https://codeberg.org/JasterV/granc.git
synced 2026-04-26 18:40:05 +00:00
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.
This commit is contained in:
parent
09478c6b19
commit
7bc2e4c0a9
35 changed files with 1285 additions and 1184 deletions
|
|
@ -1 +0,0 @@
|
||||||
granc/CHANGELOG.md
|
|
||||||
47
CHANGELOG.md
Normal file
47
CHANGELOG.md
Normal file
|
|
@ -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.
|
||||||
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -330,6 +330,16 @@ name = "granc"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
|
"granc_core",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"tonic",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "granc_core"
|
||||||
|
version = "0.2.3"
|
||||||
|
dependencies = [
|
||||||
"echo-service",
|
"echo-service",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
|
|
@ -1283,6 +1293,6 @@ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.15"
|
version = "1.0.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2"
|
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
|
||||||
|
|
|
||||||
25
Cargo.toml
25
Cargo.toml
|
|
@ -1,3 +1,26 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["granc", "echo-service"]
|
members = ["granc", "granc-core", "echo-service"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
authors = ["Victor Martínez Montané <jaster.victor@gmail.com>"]
|
||||||
|
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"
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@ args = ["run", "-p", "granc", "${@}"]
|
||||||
[tasks.test]
|
[tasks.test]
|
||||||
description = "Runs tests for the granc crate only"
|
description = "Runs tests for the granc crate only"
|
||||||
command = "cargo"
|
command = "cargo"
|
||||||
# Added '-p granc' to strictly run integration/unit tests for the CLI
|
args = ["nextest", "run", "--no-fail-fast", "--workspace"]
|
||||||
args = ["nextest", "run", "--no-fail-fast", "-p", "granc"]
|
|
||||||
|
|
||||||
[tasks.fmt]
|
[tasks.fmt]
|
||||||
description = "Formats all source files"
|
description = "Formats all source files"
|
||||||
|
|
@ -42,14 +41,13 @@ args = [
|
||||||
"warnings",
|
"warnings",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tasks.generate-reflection-service]
|
||||||
[tasks.generate_reflection_service]
|
|
||||||
description = "Runs a binary that generates a reflection service client from the reflection proto definitions"
|
description = "Runs a binary that generates a reflection service client from the reflection proto definitions"
|
||||||
command = "cargo"
|
command = "cargo"
|
||||||
args = [
|
args = [
|
||||||
"run",
|
"run",
|
||||||
"--bin",
|
"--bin",
|
||||||
"generate_reflection_service",
|
"generate-reflection-service",
|
||||||
"--features",
|
"--features",
|
||||||
"gen-proto",
|
"gen-proto",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
granc/README.md
|
|
||||||
172
README.md
Normal file
172
README.md
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
# Granc 🦀
|
||||||
|
|
||||||
|
[](https://crates.io/crates/granc)
|
||||||
|
[](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] <URL> <ENDPOINT>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arguments
|
||||||
|
|
||||||
|
| Argument | Description | Required |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `<URL>` | Server address (e.g., `http://[::1]:50051`). | **Yes** |
|
||||||
|
| `<ENDPOINT>` | 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.
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "echo-service"
|
name = "echo-service"
|
||||||
edition = "2024"
|
edition = { workspace = true }
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
||||||
46
granc-core/Cargo.toml
Normal file
46
granc-core/Cargo.toml
Normal file
|
|
@ -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 }
|
||||||
126
granc-core/README.md
Normal file
126
granc-core/README.md
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
# Granc Core
|
||||||
|
|
||||||
|
[](https://crates.io/crates/granc_core)
|
||||||
|
[](https://docs.rs/granc_core)
|
||||||
|
[](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<dyn std::error::Error>> {
|
||||||
|
// 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.
|
||||||
186
granc-core/src/client.rs
Normal file
186
granc-core/src/client.rs
Normal file
|
|
@ -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<Vec<u8>>,
|
||||||
|
pub body: serde_json::Value,
|
||||||
|
pub headers: Vec<(String, String)>,
|
||||||
|
pub service: String,
|
||||||
|
pub method: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum DynamicResponse {
|
||||||
|
Unary(Result<serde_json::Value, tonic::Status>),
|
||||||
|
Streaming(Result<Vec<Result<serde_json::Value, tonic::Status>>, tonic::Status>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GrancClient<S = Channel> {
|
||||||
|
reflection_client: ReflectionClient<S>,
|
||||||
|
grpc_client: GrpcClient<S>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GrancClient<Channel> {
|
||||||
|
pub async fn connect(addr: &str) -> Result<Self, ClientConnectError> {
|
||||||
|
let endpoint = Endpoint::new(addr.to_string())
|
||||||
|
.map_err(|e| ClientConnectError::InvalidUrl(addr.to_string(), e))?;
|
||||||
|
|
||||||
|
let channel = endpoint
|
||||||
|
.connect()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ClientConnectError::ConnectionFailed(addr.to_string(), e))?;
|
||||||
|
|
||||||
|
Ok(Self::new(channel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> GrancClient<S>
|
||||||
|
where
|
||||||
|
S: tonic::client::GrpcService<tonic::body::Body> + Clone,
|
||||||
|
S::ResponseBody: HttpBody<Data = tonic::codegen::Bytes> + Send + 'static,
|
||||||
|
<S::ResponseBody as HttpBody>::Error: Into<BoxError> + Send,
|
||||||
|
{
|
||||||
|
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<DynamicResponse, DynamicRequestError> {
|
||||||
|
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<impl Stream<Item = serde_json::Value> + Send + 'static, String> {
|
||||||
|
match json {
|
||||||
|
serde_json::Value::Array(items) => Ok(tokio_stream::iter(items)),
|
||||||
|
_ => Err("Client streaming requires a JSON Array body".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
10
granc-core/src/grpc.rs
Normal file
10
granc-core/src/grpc.rs
Normal file
|
|
@ -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;
|
||||||
|
|
@ -1,29 +1,29 @@
|
||||||
//! # Generic gRPC Client
|
//! # Generic gRPC Client
|
||||||
//!
|
//!
|
||||||
//! This module provides a wrapper around `tonic::client::Grpc` to perform network calls
|
//! This module wraps a standard `tonic` client to provide a generic interface for
|
||||||
//! without generated Rust types.
|
//! 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
|
//! ## How it works
|
||||||
//! uses the `JsonCodec` to serialize/deserialize data dynamically based on `MethodDescriptor`s.
|
|
||||||
//!
|
//!
|
||||||
//! ## 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.
|
//! ## Features
|
||||||
//! * **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.
|
|
||||||
//!
|
//!
|
||||||
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 super::codec::JsonCodec;
|
||||||
|
use crate::BoxError;
|
||||||
use futures_util::Stream;
|
use futures_util::Stream;
|
||||||
use http_body::Body as HttpBody;
|
use http_body::Body as HttpBody;
|
||||||
use prost_reflect::MethodDescriptor;
|
use prost_reflect::MethodDescriptor;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use thiserror::Error;
|
|
||||||
use tonic::{
|
use tonic::{
|
||||||
Request,
|
client::GrpcService,
|
||||||
client::Grpc,
|
|
||||||
metadata::{
|
metadata::{
|
||||||
MetadataKey, MetadataValue,
|
MetadataKey, MetadataValue,
|
||||||
errors::{InvalidMetadataKey, InvalidMetadataValue},
|
errors::{InvalidMetadataKey, InvalidMetadataValue},
|
||||||
|
|
@ -31,18 +31,8 @@ use tonic::{
|
||||||
transport::Channel,
|
transport::Channel,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
mod integration_test;
|
pub enum GrpcRequestError {
|
||||||
|
|
||||||
#[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),
|
|
||||||
#[error("Internal error, the client was not ready: '{0}'")]
|
#[error("Internal error, the client was not ready: '{0}'")]
|
||||||
ClientNotReady(#[source] BoxError),
|
ClientNotReady(#[source] BoxError),
|
||||||
#[error("Invalid metadata (header) key '{key}': '{source}'")]
|
#[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`.
|
/// A generic client for the gRPC Server Reflection Protocol.
|
||||||
///
|
pub struct GrpcClient<S = Channel> {
|
||||||
/// It can wrap any inner service `T` that implements `GrpcService<tonic::body::Body>`,
|
client: tonic::client::Grpc<S>,
|
||||||
/// such as `tonic::transport::Channel` or a service generated by `tonic-build`.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct GrpcClient<T = Channel> {
|
|
||||||
service: T,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implementation for the standard network client using `Channel`.
|
|
||||||
impl GrpcClient<Channel> {
|
|
||||||
/// 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<Self, ClientError> {
|
|
||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S> GrpcClient<S>
|
impl<S> GrpcClient<S>
|
||||||
where
|
where
|
||||||
S: tonic::client::GrpcService<tonic::body::Body> + Clone,
|
S: GrpcService<tonic::body::Body>,
|
||||||
S::ResponseBody: HttpBody + Send + 'static,
|
S::Error: Into<BoxError>,
|
||||||
<S::ResponseBody as HttpBody>::Error: Into<BoxError>,
|
S::ResponseBody: HttpBody<Data = tonic::codegen::Bytes> + Send + 'static,
|
||||||
|
<S::ResponseBody as HttpBody>::Error: Into<BoxError> + 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).
|
/// Performs a Unary gRPC call (Single Request -> Single Response).
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
|
|
@ -101,22 +71,21 @@ where
|
||||||
/// * `Ok(Err(Status))` - RPC executed, but server returned an error.
|
/// * `Ok(Err(Status))` - RPC executed, but server returned an error.
|
||||||
/// * `Err(ClientError)` - Failed to send request or connect.
|
/// * `Err(ClientError)` - Failed to send request or connect.
|
||||||
pub async fn unary(
|
pub async fn unary(
|
||||||
&self,
|
&mut self,
|
||||||
method: MethodDescriptor,
|
method: MethodDescriptor,
|
||||||
payload: serde_json::Value,
|
payload: serde_json::Value,
|
||||||
headers: Vec<(String, String)>,
|
headers: Vec<(String, String)>,
|
||||||
) -> Result<Result<serde_json::Value, tonic::Status>, ClientError> {
|
) -> Result<Result<serde_json::Value, tonic::Status>, GrpcRequestError> {
|
||||||
let mut client = Grpc::new(self.service.clone());
|
self.client
|
||||||
client
|
|
||||||
.ready()
|
.ready()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ClientError::ClientNotReady(e.into()))?;
|
.map_err(|e| GrpcRequestError::ClientNotReady(e.into()))?;
|
||||||
|
|
||||||
let codec = JsonCodec::new(method.input(), method.output());
|
let codec = JsonCodec::new(method.input(), method.output());
|
||||||
let path = http_path(&method);
|
let path = http_path(&method);
|
||||||
let request = build_request(payload, headers)?;
|
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())),
|
Ok(response) => Ok(Ok(response.into_inner())),
|
||||||
Err(status) => Ok(Err(status)),
|
Err(status) => Ok(Err(status)),
|
||||||
}
|
}
|
||||||
|
|
@ -130,25 +99,24 @@ where
|
||||||
/// * `Ok(Err(Status))` - RPC executed, but server returned an error.
|
/// * `Ok(Err(Status))` - RPC executed, but server returned an error.
|
||||||
/// * `Err(ClientError)` - Failed to send request or connect.
|
/// * `Err(ClientError)` - Failed to send request or connect.
|
||||||
pub async fn server_streaming(
|
pub async fn server_streaming(
|
||||||
&self,
|
&mut self,
|
||||||
method: MethodDescriptor,
|
method: MethodDescriptor,
|
||||||
payload: serde_json::Value,
|
payload: serde_json::Value,
|
||||||
headers: Vec<(String, String)>,
|
headers: Vec<(String, String)>,
|
||||||
) -> Result<
|
) -> Result<
|
||||||
Result<impl Stream<Item = Result<serde_json::Value, tonic::Status>>, tonic::Status>,
|
Result<impl Stream<Item = Result<serde_json::Value, tonic::Status>>, tonic::Status>,
|
||||||
ClientError,
|
GrpcRequestError,
|
||||||
> {
|
> {
|
||||||
let mut client = Grpc::new(self.service.clone());
|
self.client
|
||||||
client
|
|
||||||
.ready()
|
.ready()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ClientError::ClientNotReady(e.into()))?;
|
.map_err(|e| GrpcRequestError::ClientNotReady(e.into()))?;
|
||||||
|
|
||||||
let codec = JsonCodec::new(method.input(), method.output());
|
let codec = JsonCodec::new(method.input(), method.output());
|
||||||
let path = http_path(&method);
|
let path = http_path(&method);
|
||||||
let request = build_request(payload, headers)?;
|
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())),
|
Ok(response) => Ok(Ok(response.into_inner())),
|
||||||
Err(status) => Ok(Err(status)),
|
Err(status) => Ok(Err(status)),
|
||||||
}
|
}
|
||||||
|
|
@ -162,22 +130,21 @@ where
|
||||||
/// * `Ok(Err(Status))` - RPC executed, but server returned an error.
|
/// * `Ok(Err(Status))` - RPC executed, but server returned an error.
|
||||||
/// * `Err(ClientError)` - Failed to send request or connect.
|
/// * `Err(ClientError)` - Failed to send request or connect.
|
||||||
pub async fn client_streaming(
|
pub async fn client_streaming(
|
||||||
&self,
|
&mut self,
|
||||||
method: MethodDescriptor,
|
method: MethodDescriptor,
|
||||||
payload_stream: impl Stream<Item = serde_json::Value> + Send + 'static,
|
payload_stream: impl Stream<Item = serde_json::Value> + Send + 'static,
|
||||||
headers: Vec<(String, String)>,
|
headers: Vec<(String, String)>,
|
||||||
) -> Result<Result<serde_json::Value, tonic::Status>, ClientError> {
|
) -> Result<Result<serde_json::Value, tonic::Status>, GrpcRequestError> {
|
||||||
let mut client = Grpc::new(self.service.clone());
|
self.client
|
||||||
client
|
|
||||||
.ready()
|
.ready()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ClientError::ClientNotReady(e.into()))?;
|
.map_err(|e| GrpcRequestError::ClientNotReady(e.into()))?;
|
||||||
|
|
||||||
let codec = JsonCodec::new(method.input(), method.output());
|
let codec = JsonCodec::new(method.input(), method.output());
|
||||||
let path = http_path(&method);
|
let path = http_path(&method);
|
||||||
let request = build_request(payload_stream, headers)?;
|
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())),
|
Ok(response) => Ok(Ok(response.into_inner())),
|
||||||
Err(status) => Ok(Err(status)),
|
Err(status) => Ok(Err(status)),
|
||||||
}
|
}
|
||||||
|
|
@ -191,25 +158,24 @@ where
|
||||||
/// * `Ok(Err(Status))` - RPC executed, but server returned an error.
|
/// * `Ok(Err(Status))` - RPC executed, but server returned an error.
|
||||||
/// * `Err(ClientError)` - Failed to send request or connect.
|
/// * `Err(ClientError)` - Failed to send request or connect.
|
||||||
pub async fn bidirectional_streaming(
|
pub async fn bidirectional_streaming(
|
||||||
&self,
|
&mut self,
|
||||||
method: MethodDescriptor,
|
method: MethodDescriptor,
|
||||||
payload_stream: impl Stream<Item = serde_json::Value> + Send + 'static,
|
payload_stream: impl Stream<Item = serde_json::Value> + Send + 'static,
|
||||||
headers: Vec<(String, String)>,
|
headers: Vec<(String, String)>,
|
||||||
) -> Result<
|
) -> Result<
|
||||||
Result<impl Stream<Item = Result<serde_json::Value, tonic::Status>>, tonic::Status>,
|
Result<impl Stream<Item = Result<serde_json::Value, tonic::Status>>, tonic::Status>,
|
||||||
ClientError,
|
GrpcRequestError,
|
||||||
> {
|
> {
|
||||||
let mut client = Grpc::new(self.service.clone());
|
self.client
|
||||||
client
|
|
||||||
.ready()
|
.ready()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ClientError::ClientNotReady(e.into()))?;
|
.map_err(|e| GrpcRequestError::ClientNotReady(e.into()))?;
|
||||||
|
|
||||||
let codec = JsonCodec::new(method.input(), method.output());
|
let codec = JsonCodec::new(method.input(), method.output());
|
||||||
let path = http_path(&method);
|
let path = http_path(&method);
|
||||||
let request = build_request(payload_stream, headers)?;
|
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())),
|
Ok(response) => Ok(Ok(response.into_inner())),
|
||||||
Err(status) => Ok(Err(status)),
|
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")
|
http::uri::PathAndQuery::from_str(&path).expect("valid gRPC path")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_request<T>(payload: T, headers: Vec<(String, String)>) -> Result<Request<T>, ClientError> {
|
fn build_request<T>(
|
||||||
let mut request = Request::new(payload);
|
payload: T,
|
||||||
|
headers: Vec<(String, String)>,
|
||||||
|
) -> Result<tonic::Request<T>, GrpcRequestError> {
|
||||||
|
let mut request = tonic::Request::new(payload);
|
||||||
for (k, v) in headers {
|
for (k, v) in headers {
|
||||||
let key = MetadataKey::from_str(&k).map_err(|source| ClientError::InvalidMetadataKey {
|
let key =
|
||||||
|
MetadataKey::from_str(&k).map_err(|source| GrpcRequestError::InvalidMetadataKey {
|
||||||
key: k.clone(),
|
key: k.clone(),
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
let val = MetadataValue::from_str(&v)
|
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);
|
request.metadata_mut().insert(key, val);
|
||||||
}
|
}
|
||||||
Ok(request)
|
Ok(request)
|
||||||
50
granc-core/src/lib.rs
Normal file
50
granc-core/src/lib.rs
Normal file
|
|
@ -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<dyn std::error::Error + Send + Sync + 'static>;
|
||||||
8
granc-core/src/reflection.rs
Normal file
8
granc-core/src/reflection.rs
Normal file
|
|
@ -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;
|
||||||
223
granc-core/src/reflection/client.rs
Normal file
223
granc-core/src/reflection/client.rs
Normal file
|
|
@ -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<T = Channel> {
|
||||||
|
client: ServerReflectionClient<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> ReflectionClient<S>
|
||||||
|
where
|
||||||
|
S: GrpcService<tonic::body::Body>,
|
||||||
|
S::Error: Into<BoxError>,
|
||||||
|
S::ResponseBody: HttpBody<Data = tonic::codegen::Bytes> + Send + 'static,
|
||||||
|
<S::ResponseBody as HttpBody>::Error: Into<BoxError> + 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<FileDescriptorSet, ReflectionResolveError> {
|
||||||
|
// 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<ServerReflectionResponse>,
|
||||||
|
request_channel: mpsc::Sender<ServerReflectionRequest>,
|
||||||
|
) -> Result<HashMap<String, FileDescriptorProto>, 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<Vec<u8>>,
|
||||||
|
collected_files: &mut HashMap<String, FileDescriptorProto>,
|
||||||
|
requested: &mut HashSet<String>,
|
||||||
|
tx: &mpsc::Sender<ServerReflectionRequest>,
|
||||||
|
) -> Result<usize, ReflectionResolveError> {
|
||||||
|
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<String, FileDescriptorProto>,
|
||||||
|
requested: &mut HashSet<String>,
|
||||||
|
tx: &mpsc::Sender<ServerReflectionRequest>,
|
||||||
|
) -> Result<usize, ReflectionResolveError> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
use echo_service::{
|
use echo_service::EchoService;
|
||||||
EchoService,
|
use echo_service::pb::{EchoRequest, EchoResponse};
|
||||||
pb::{EchoRequest, EchoResponse},
|
|
||||||
};
|
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
use tonic::{Request, Response, Status, Streaming};
|
use tonic::{Request, Response, Status, Streaming};
|
||||||
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
use echo_service::EchoService;
|
use echo_service::EchoService;
|
||||||
use echo_service::pb::{EchoRequest, EchoResponse};
|
use echo_service::pb::{EchoRequest, EchoResponse};
|
||||||
|
|
||||||
use futures_util::Stream;
|
use futures_util::Stream;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
128
granc-core/tests/granc_client_test.rs
Normal file
128
granc-core/tests/granc_client_test.rs
Normal file
|
|
@ -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"),
|
||||||
|
};
|
||||||
|
}
|
||||||
138
granc-core/tests/reflection_client_test.rs
Normal file
138
granc-core/tests/reflection_client_test.rs
Normal file
|
|
@ -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<ServerReflectionServer<impl tonic_reflection::server::v1::ServerReflection>> {
|
||||||
|
// 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -1,48 +1,21 @@
|
||||||
[package]
|
[package]
|
||||||
authors = ["Victor Martínez Montané <jaster.victor@gmail.com>"]
|
authors = { workspace = true }
|
||||||
categories = ["network-programming", "command-line-utilities"]
|
categories = ["network-programming", "command-line-utilities"]
|
||||||
description = "A dynamic gRPC CLI tool written in Rust (gRPC + Cranc, Crab in Catalan)"
|
description = "A dynamic gRPC CLI tool written in Rust (gRPC + Cranc, Crab in Catalan)"
|
||||||
edition = "2024"
|
edition = { workspace = true }
|
||||||
homepage = "https://github.com/JasterV/granc"
|
homepage = { workspace = true }
|
||||||
keywords = ["cli", "command-line", "grpc", "grpcurl", "curl"]
|
keywords = ["cli", "command-line", "grpc", "grpcurl", "curl"]
|
||||||
license = "MIT OR Apache-2.0"
|
license = { workspace = true }
|
||||||
name = "granc"
|
name = "granc"
|
||||||
publish = true
|
publish = true
|
||||||
readme = "README.md"
|
readme = "../README.md"
|
||||||
repository = "https://github.com/JasterV/granc"
|
repository = { workspace = true }
|
||||||
rust-version = "1.89"
|
rust-version = { workspace = true }
|
||||||
version = "0.2.3"
|
version = { workspace = true }
|
||||||
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"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.5.54", features = ["derive"] }
|
clap = { version = "4.5.54", features = ["derive"] }
|
||||||
futures-util = "0.3.31"
|
granc_core = { path = "../granc-core" }
|
||||||
http = "1.4.0"
|
serde_json = { workspace = true }
|
||||||
http-body = "1.0.1"
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||||
prost = "0.14.3"
|
tonic = { workspace = true }
|
||||||
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"
|
|
||||||
|
|
|
||||||
171
granc/README.md
171
granc/README.md
|
|
@ -1,171 +0,0 @@
|
||||||
# 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] <URL> <ENDPOINT>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Arguments
|
|
||||||
|
|
||||||
| Argument | Description | Required |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `<URL>` | Server address (e.g., `http://[::1]:50051`). | **Yes** |
|
|
||||||
| `<ENDPOINT>` | 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.
|
|
||||||
|
|
@ -1,19 +1,16 @@
|
||||||
//! # Command Line Interface Definition
|
//! # CLI
|
||||||
//!
|
//!
|
||||||
//! This module utilizes `clap` to define the command-line arguments and flags
|
//! This module defines the command-line interface of `granc` using `clap`.
|
||||||
//! 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.
|
|
||||||
//!
|
//!
|
||||||
|
//! It is responsible for parsing user input and performing validation (e.g., ensuring headers are `key:value`);
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use std::path::PathBuf;
|
use granc_core::client::DynamicRequest;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "granc", version, about = "Dynamic gRPC CLI")]
|
#[command(name = "granc", version, about = "Dynamic gRPC CLI")]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
#[arg(long, help = "Path to the descriptor set (.bin)")]
|
#[arg(long, help = "Path to the descriptor set (.bin)", value_parser = parse_file_descriptor_set)]
|
||||||
pub proto_set: Option<PathBuf>,
|
pub file_descriptor_set: Option<Vec<u8>>,
|
||||||
|
|
||||||
#[arg(long, help = "JSON body (Object for Unary, Array for Streaming)", value_parser = parse_body)]
|
#[arg(long, help = "JSON body (Object for Unary, Array for Streaming)", value_parser = parse_body)]
|
||||||
pub body: serde_json::Value,
|
pub body: serde_json::Value,
|
||||||
|
|
@ -28,22 +25,28 @@ pub struct Cli {
|
||||||
pub endpoint: (String, String),
|
pub endpoint: (String, String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Cli> for crate::core::Input {
|
impl From<Cli> for DynamicRequest {
|
||||||
/// Converts the raw CLI arguments into the internal `Input` representation.
|
/// Converts the raw CLI arguments into the internal `Input` representation.
|
||||||
fn from(value: Cli) -> Self {
|
fn from(value: Cli) -> Self {
|
||||||
let (service, method) = value.endpoint;
|
let (service, method) = value.endpoint;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
proto_set: value.proto_set,
|
file_descriptor_set: value.file_descriptor_set,
|
||||||
body: value.body,
|
body: value.body,
|
||||||
headers: value.headers,
|
headers: value.headers,
|
||||||
url: value.url,
|
|
||||||
service,
|
service,
|
||||||
method,
|
method,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_file_descriptor_set(path: &str) -> Result<Vec<u8>, 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> {
|
fn parse_endpoint(value: &str) -> Result<(String, String), String> {
|
||||||
let (service, method) = value.split_once('/').ok_or_else(|| {
|
let (service, method) = value.split_once('/').ok_or_else(|| {
|
||||||
format!("Invalid endpoint format: '{value}'. Expected 'package.Service/Method'",)
|
format!("Invalid endpoint format: '{value}'. Expected 'package.Service/Method'",)
|
||||||
|
|
|
||||||
|
|
@ -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<dyn std::error::Error + Send + Sync + 'static>;
|
|
||||||
|
|
||||||
/// Request parameters (URL, Body, Headers... etc.).
|
|
||||||
pub struct Input {
|
|
||||||
pub proto_set: Option<PathBuf>,
|
|
||||||
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<serde_json::Value, tonic::Status>),
|
|
||||||
Streaming(Result<Vec<Result<serde_json::Value, tonic::Status>>, 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<Output, CoreError> {
|
|
||||||
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<Output, CoreError> {
|
|
||||||
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<Output, CoreError> {
|
|
||||||
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<Output, CoreError> {
|
|
||||||
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<Output, CoreError> {
|
|
||||||
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<impl Stream<Item = serde_json::Value> + 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(),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
pub mod client;
|
|
||||||
mod generated;
|
|
||||||
pub mod registry;
|
|
||||||
|
|
||||||
pub use client::ReflectionClient;
|
|
||||||
pub use registry::DescriptorRegistry;
|
|
||||||
|
|
@ -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<T = Channel> {
|
|
||||||
client: ServerReflectionClient<T>,
|
|
||||||
base_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implementation for the standard network client.
|
|
||||||
impl ReflectionClient<Channel> {
|
|
||||||
pub async fn connect(base_url: String) -> Result<Self, ReflectionConnectError> {
|
|
||||||
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<T> ReflectionClient<T>
|
|
||||||
where
|
|
||||||
T: GrpcService<tonic::body::Body>,
|
|
||||||
T::Error: Into<BoxError>,
|
|
||||||
T::ResponseBody: HttpBody<Data = tonic::codegen::Bytes> + Send + 'static,
|
|
||||||
<T::ResponseBody as HttpBody>::Error: Into<BoxError> + 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<FileDescriptorSet, ReflectionResolveError> {
|
|
||||||
// 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<ServerReflectionResponse>,
|
|
||||||
request_channel: mpsc::Sender<ServerReflectionRequest>,
|
|
||||||
) -> Result<HashMap<String, FileDescriptorProto>, 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<Vec<u8>>,
|
|
||||||
collected_files: &mut HashMap<String, FileDescriptorProto>,
|
|
||||||
requested: &mut HashSet<String>,
|
|
||||||
tx: &mpsc::Sender<ServerReflectionRequest>,
|
|
||||||
) -> Result<usize, ReflectionResolveError> {
|
|
||||||
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<String, FileDescriptorProto>,
|
|
||||||
requested: &mut HashSet<String>,
|
|
||||||
tx: &mpsc::Sender<ServerReflectionRequest>,
|
|
||||||
) -> Result<usize, ReflectionResolveError> {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<ServerReflectionServer<impl tonic_reflection::server::v1::ServerReflection>> {
|
|
||||||
// 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"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<Self, DescriptorError> {
|
|
||||||
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<Self, DescriptorError> {
|
|
||||||
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<Path>) -> Result<Self, DescriptorError> {
|
|
||||||
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<MethodDescriptor, DescriptorError> {
|
|
||||||
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()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 cli;
|
||||||
mod core;
|
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use cli::Cli;
|
use cli::Cli;
|
||||||
|
use granc_core::client::{DynamicRequest, DynamicResponse, GrancClient};
|
||||||
use std::process;
|
use std::process;
|
||||||
|
|
||||||
use crate::core::Output;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let args = Cli::parse();
|
let args = Cli::parse();
|
||||||
|
|
||||||
match core::run(core::Input::from(args)).await {
|
let mut client = match GrancClient::connect(&args.url).await {
|
||||||
Ok(Output::Unary(Ok(value))) => print_json(&value),
|
Ok(client) => client,
|
||||||
Ok(Output::Unary(Err(status))) => print_status(&status),
|
Err(err) => {
|
||||||
Ok(Output::Streaming(Ok(values))) => print_stream(&values),
|
eprintln!("Error: {err}");
|
||||||
Ok(Output::Streaming(Err(status))) => print_status(&status),
|
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) => {
|
Err(err) => {
|
||||||
eprintln!("Error: {err}");
|
eprintln!("Error: {err}");
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
# set the path of all the crates to the changelog to the root of the repository
|
# 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_draft = true
|
||||||
pr_labels = ["release"]
|
pr_labels = ["release"]
|
||||||
pr_branch_prefix = "release-"
|
pr_branch_prefix = "release-"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue