mirror of
https://codeberg.org/JasterV/granc.git
synced 2026-04-26 18:40:05 +00:00
Compare commits
55 commits
granc-v0.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a69984daa | ||
|
|
85a9fd47bc | ||
|
|
e1c741f5de | ||
|
|
74b4f3aac0 | ||
|
|
288f8a06e3 | ||
|
|
43acb93dd1 | ||
|
|
69f1115cbe | ||
|
|
23af9fc8a0 | ||
|
|
9ce4f73189 |
||
|
|
1efdee7a72 |
||
|
|
91ca259f71 |
||
|
|
50d49902ad |
||
|
|
5d2ae58682 |
||
|
|
7b5390c2b5 |
||
|
|
d79a7a5609 |
||
|
|
eb3fc20a33 | ||
|
|
d6bffd82dc | ||
|
|
b32827a903 | ||
|
|
322ba2a355 | ||
|
|
4494cc7596 |
||
|
|
a8b012d6bc |
||
|
|
c9ef611e07 |
||
|
|
bc5a13cc79 |
||
|
|
913baa6c1e |
||
|
|
dd52cf47d8 |
||
|
|
cda75f8124 |
||
|
|
011904c2e5 |
||
|
|
c05b4fe865 | ||
|
|
687654c46f |
||
|
|
80ce6d7668 |
||
|
|
9990e94c8c |
||
|
|
8ce153e271 |
||
|
|
69336d850e |
||
|
|
57381ca520 |
||
|
|
d9001fc87e |
||
|
|
d74d8a6bf2 |
||
|
|
17e6fe57a0 |
||
|
|
772b3a45b9 | ||
|
|
26e46a4003 |
||
|
|
191120c1d4 | ||
|
|
efe41e1155 |
||
|
|
81ac1d4be1 | ||
|
|
21b0b524d2 |
||
|
|
8452a6786b |
||
|
|
f75dc1b9a4 | ||
|
|
7bc2e4c0a9 |
||
|
|
09478c6b19 |
||
|
|
27674a03ab | ||
|
|
5f6ffaa3ab | ||
|
|
949dad63ff | ||
|
|
cd63af7436 |
||
|
|
eaf99b1034 | ||
|
|
e5b1296ab5 |
||
|
|
8cc7003344 |
||
|
|
81b4d1ac1c |
56 changed files with 4448 additions and 1059 deletions
11
.github/dependabot.yml
vendored
11
.github/dependabot.yml
vendored
|
|
@ -1,11 +0,0 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "09:00"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
53
.github/workflows/cd.yml
vendored
53
.github/workflows/cd.yml
vendored
|
|
@ -1,53 +0,0 @@
|
|||
name: Release-plz
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
|
||||
# Release unpublished packages.
|
||||
release-plz-release:
|
||||
name: Release-plz release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- &checkout
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- &install-rust
|
||||
name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Run release-plz
|
||||
uses: release-plz/action@v0.5
|
||||
with:
|
||||
command: release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_AUTH_KEY }}
|
||||
|
||||
# Create a PR with the new versions and changelog, preparing the next release.
|
||||
release-plz-pr:
|
||||
name: Release-plz PR
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
concurrency:
|
||||
group: release-plz-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- *checkout
|
||||
- *install-rust
|
||||
- name: Run release-plz
|
||||
uses: release-plz/action@v0.5
|
||||
with:
|
||||
command: release-pr
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_AUTH_KEY }}
|
||||
36
.github/workflows/ci.yml
vendored
36
.github/workflows/ci.yml
vendored
|
|
@ -1,36 +0,0 @@
|
|||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- run: rustup default 1.92.0
|
||||
- run: rustup component add clippy rustfmt
|
||||
- uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # ratchet:Swatinem/rust-cache@v2
|
||||
- uses: taiki-e/install-action@ae532dedd825648efd18d9c49c9a443d0398ca0a # ratchet:taiki-e/install-action@cargo-make
|
||||
- name: Install protoc compiler
|
||||
run: |
|
||||
sudo apt update -y
|
||||
sudo apt install -y protobuf-compiler
|
||||
- run: cargo make ci
|
||||
- run: cargo make test
|
||||
|
||||
alls-green:
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- ci
|
||||
steps:
|
||||
- run: ${{ !contains(needs.*.result, 'failure') }}
|
||||
23
.woodpecker/cd.yml
Normal file
23
.woodpecker/cd.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
when:
|
||||
event: push
|
||||
branch: main
|
||||
|
||||
depends_on:
|
||||
- ci
|
||||
|
||||
steps:
|
||||
- name: Release unpublished
|
||||
image: codeberg.org/jasterv/rust-magic-release:latest
|
||||
pull: true
|
||||
settings:
|
||||
token:
|
||||
from_secret: CODEBERG_TOKEN
|
||||
crates_io_token:
|
||||
from_secret: CRATES_IO_TOKEN
|
||||
|
||||
- name: Update PR
|
||||
image: codeberg.org/jasterv/release-plz-update-pr:latest
|
||||
pull: true
|
||||
settings:
|
||||
token:
|
||||
from_secret: CODEBERG_TOKEN
|
||||
11
.woodpecker/ci.yml
Normal file
11
.woodpecker/ci.yml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
when:
|
||||
- event: [push, pull_request]
|
||||
branch: main
|
||||
|
||||
steps:
|
||||
ci:
|
||||
image: codeberg.org/jasterv/rust-ci:1.93
|
||||
commands:
|
||||
- apt update -y
|
||||
- apt-get install -y protobuf-compiler
|
||||
- cargo make ci
|
||||
27
.woodpecker/release-binary.yml
Normal file
27
.woodpecker/release-binary.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
when:
|
||||
event: tag
|
||||
ref: refs/tags/granc-v*
|
||||
|
||||
steps:
|
||||
build:
|
||||
image: rust:latest
|
||||
pull: true
|
||||
commands:
|
||||
- apt update -y
|
||||
- apt-get install -y protobuf-compiler
|
||||
- cargo build --release --target x86_64-unknown-linux-gnu
|
||||
|
||||
package:
|
||||
image: alpine
|
||||
commands:
|
||||
- apk add --no-cache tar gzip
|
||||
- tar -czf granc-x86_64-unknown-linux-gnu.tgz -C target/x86_64-unknown-linux-gnu/release granc
|
||||
|
||||
publish:
|
||||
image: woodpeckerci/plugin-release
|
||||
settings:
|
||||
base_url: https://codeberg.org
|
||||
files:
|
||||
- granc-x86_64-unknown-linux-gnu.tgz
|
||||
api_key:
|
||||
from_secret: CODEBERG_TOKEN
|
||||
|
|
@ -1 +0,0 @@
|
|||
granc/CHANGELOG.md
|
||||
162
CHANGELOG.md
Normal file
162
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
# 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.7.6](https://codeberg.org/JasterV/granc/compare/granc-v0.7.5...granc-v0.7.6) - 2026-04-22
|
||||
|
||||
### Other
|
||||
- update dependencies
|
||||
|
||||
## `granc_core` - [0.6.4](https://codeberg.org/JasterV/granc/compare/granc_core-v0.6.3...granc_core-v0.6.4) - 2026-04-22
|
||||
|
||||
### Other
|
||||
- update Cargo.toml dependencies
|
||||
|
||||
## `granc` - [0.7.5](https://codeberg.org/JasterV/granc/compare/granc-v0.7.4...granc-v0.7.5) - 2026-04-22
|
||||
|
||||
- refactor: use streams for streaming responses' ([#2](https://codeberg.org/JasterV/granc/pulls/2))
|
||||
|
||||
## `granc_core` - [0.6.3](https://codeberg.org/JasterV/granc/compare/granc_core-v0.6.2...granc_core-v0.6.3) - 2026-04-22
|
||||
|
||||
- refactor: use streams for streaming responses' ([#2](https://codeberg.org/JasterV/granc/pulls/2))
|
||||
|
||||
## `granc` - [0.7.4](https://codeberg.org/JasterV/granc/compare/granc-v0.7.3...granc-v0.7.4) - 2026-03-09
|
||||
|
||||
- [chore] Migration to Codeberg
|
||||
|
||||
## `granc_core` - [0.6.2](https://codeberg.org/JasterV/granc/compare/granc_core-v0.6.1...granc_core-v0.6.2) - 2026-03-09
|
||||
|
||||
- [chore] Migration to Codeberg
|
||||
|
||||
## `granc` - [0.7.3](https://github.com/JasterV/granc/compare/granc-v0.7.2...granc-v0.7.3) - 2026-02-11
|
||||
|
||||
- [fix] Support for cargo binstall
|
||||
|
||||
## `granc` - [0.7.2](https://github.com/JasterV/granc/compare/granc-v0.7.1...granc-v0.7.2) - 2026-02-11
|
||||
|
||||
- [chore] Add support for cargo binstall
|
||||
|
||||
## `granc` - [0.7.1](https://github.com/JasterV/granc/compare/granc-v0.7.0...granc-v0.7.1) - 2026-02-06
|
||||
|
||||
- [feat] Add a new command to generate markdown documentation for gRPC services ([#46](https://github.com/JasterV/granc/pull/46))
|
||||
- *(deps)* bump clap from 4.5.55 to 4.5.56 ([#45](https://github.com/JasterV/granc/pull/45))
|
||||
|
||||
## `granc_core` - [0.6.1](https://github.com/JasterV/granc/compare/granc_core-v0.6.0...granc_core-v0.6.1) - 2026-02-06
|
||||
|
||||
- Added `name`, `full_name`, and `package_name` methods to `Descriptor` to simplify access to descriptor metadata.
|
||||
|
||||
## `granc` - [0.7.0](https://github.com/JasterV/granc/compare/granc-v0.6.0...granc-v0.7.0) - 2026-01-28
|
||||
|
||||
- *(deps)* bump clap from 4.5.54 to 4.5.55 ([#36](https://github.com/JasterV/granc/pull/36))
|
||||
- [fix] A URL should not be required for list and describe commands ([#35](https://github.com/JasterV/granc/pull/35))
|
||||
- [test] Added comprehensive tests for CLI argument parsing and validation.
|
||||
|
||||
## `granc_core` - [0.6.0](https://github.com/JasterV/granc/compare/granc_core-v0.5.0...granc_core-v0.6.0) - 2026-01-28
|
||||
|
||||
- [refactor] Now the `GrancClient` also provides a full offline state and other states have been renamed to be more idiomatic ([#35](https://github.com/JasterV/granc/pull/35))
|
||||
|
||||
## `granc` - [0.6.0](https://github.com/JasterV/granc/compare/granc-v0.5.1...granc-v0.6.0) - 2026-01-27
|
||||
|
||||
- Make the `--file-descriptor-set` a global option for all commands, so reflection commands can also be executed against a local descriptor. ([#28](https://github.com/JasterV/granc/pull/28))
|
||||
|
||||
## `granc_core` - [0.5.0](https://github.com/JasterV/granc/compare/granc_core-v0.4.1...granc_core-v0.5.0) - 2026-01-27
|
||||
|
||||
- **Typestate design refactor**: The GrancClient has been refactored to support multiple states where invariants for each state are ensured by the compiler. ([#28](https://github.com/JasterV/granc/pull/28))
|
||||
- The GrancClient can be in either a `WithServerReflection` state or a `WithFileDescriptor` state, and both states have independent APIs (Async vs sync).
|
||||
|
||||
## `granc` - [0.5.1](https://github.com/JasterV/granc/compare/granc-v0.5.0...granc-v0.5.1) - 2026-01-24
|
||||
|
||||
### Other
|
||||
|
||||
- **Update deps**: Update `granc_core` to `0.4.1`
|
||||
|
||||
## `granc_core` - [0.4.1](https://github.com/JasterV/granc/compare/granc_core-v0.4.0...granc_core-v0.4.1) - 2026-01-27
|
||||
|
||||
### Other
|
||||
- **Internal clean up**: We've replaced our own script that generated the Reflection client to use `tonic-reflection` instead. ([#29](https://github.com/JasterV/granc/pull/29))
|
||||
|
||||
## `granc` - [0.5.0](https://github.com/JasterV/granc/compare/granc-v0.2.4...granc-v0.5.0) - 2026-01-24
|
||||
|
||||
### Added
|
||||
|
||||
- **Introspection Commands**:
|
||||
- `list`: Lists all services available on the server (requires reflection).
|
||||
- `describe`: Lists all methods within a specific service, prints the Protobuf definition of a message type or show all the variants of an enum.
|
||||
- **Formatted Output**: Added colored output for Protobuf definitions, JSON responses, and error messages.
|
||||
|
||||
### Changed
|
||||
|
||||
- **[BREAKING] New CLI Structure**: The CLI now enforces a `granc <URL> <COMMAND>` structure.
|
||||
- Previous implicit calls are now explicit: `granc http://... call <ENDPOINT> ...`.
|
||||
- The URL is now a global positional argument required for all commands.
|
||||
|
||||
## `granc_core` - [0.4.0](https://github.com/JasterV/granc/compare/granc_core-v0.3.1...granc_core-v0.4.0) - 2026-01-24
|
||||
|
||||
### Added
|
||||
|
||||
- **Introspection APIs**: Added `list_services` and `get_descriptor_by_symbol` to `GrancClient`.
|
||||
- **Reflection Support**: Updated `ReflectionClient` to support the `ListServices` reflection method.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Error Handling Refactor**: Overhauled error types to be more specific per method (`GetDescriptoError`, `ListServicesError`) and reduced internal duplication.
|
||||
|
||||
## `granc` - [0.4.0](https://github.com/JasterV/granc/compare/granc-v0.2.4...granc-v0.4.0) - 2026-01-22
|
||||
|
||||
- Made a mistake publishing `granc 0.3` and introduced bugs, `granc 0.4` fixes them and its the first working version after `0.2.4`.
|
||||
|
||||
## `granc_core` - [0.3.1](https://github.com/JasterV/granc/compare/granc_core-v0.3.0...granc_core-v0.3.1) - 2026-01-22
|
||||
|
||||
### Other
|
||||
|
||||
- Update granc-core documentation
|
||||
|
||||
## `granc_core` - [0.3.0](https://github.com/JasterV/granc/compare/granc_core-v0.2.4...granc_core-v0.3.0) - 2026-01-22
|
||||
|
||||
- Fix: separate reflection generation binary to not be published ([#20](https://github.com/JasterV/granc/pull/20))
|
||||
|
||||
## `granc` - [0.2.4](https://github.com/JasterV/granc/compare/granc-v0.2.3...granc-v0.2.4) - 2026-01-22
|
||||
|
||||
- **Published granc-core** as a library crate `granc-core` ([#16](https://github.com/JasterV/granc/pull/16))
|
||||
|
||||
## `granc_core` - [0.2.4](https://github.com/JasterV/granc/compare/granc_core-v0.2.3...granc_core-v0.2.4) - 2026-01-22
|
||||
|
||||
- **Published granc-core** as a library crate `granc-core` ([#16](https://github.com/JasterV/granc/pull/16))
|
||||
|
||||
## `granc` - [0.2.3](https://github.com/JasterV/granc/compare/granc-v0.2.2...granc-v0.2.3) - 2026-01-21
|
||||
|
||||
- **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
|
||||
|
||||
- Updated README.md
|
||||
|
||||
## `granc` - [0.2.1](https://github.com/JasterV/granc/compare/granc-v0.2.0...granc-v0.2.1) - 2026-01-21
|
||||
|
||||
- Updated 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.
|
||||
539
Cargo.lock
generated
539
Cargo.lock
generated
|
|
@ -13,9 +13,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
|
|
@ -28,15 +28,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.13"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.7"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
|
@ -47,7 +47,7 @@ version = "1.1.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -58,14 +58,14 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.100"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
|
|
@ -92,9 +92,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"bytes",
|
||||
|
|
@ -141,15 +141,15 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
|||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.10.0"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.0"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
|
|
@ -159,9 +159,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.54"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
|
||||
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
|
|
@ -169,9 +169,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.54"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
|
|
@ -181,9 +181,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.49"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
|
||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
|
|
@ -193,26 +193,23 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.7.7"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.4"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "echo-service"
|
||||
version = "0.0.0"
|
||||
name = "colored"
|
||||
version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost",
|
||||
"prost-types",
|
||||
"tonic",
|
||||
"tonic-prost",
|
||||
"tonic-prost-build",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -234,14 +231,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
|
|
@ -263,24 +260,24 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
|||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -289,59 +286,86 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-macro",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "granc"
|
||||
version = "0.1.0"
|
||||
version = "0.7.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"echo-service",
|
||||
"colored",
|
||||
"futures-util",
|
||||
"granc-test-support",
|
||||
"granc_core",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "granc-test-support"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost",
|
||||
"prost-build",
|
||||
"prost-types",
|
||||
"tempfile",
|
||||
"tonic",
|
||||
"tonic-prost",
|
||||
"tonic-prost-build",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "granc_core"
|
||||
version = "0.6.4"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"granc-test-support",
|
||||
"http",
|
||||
"http-body",
|
||||
"prost",
|
||||
"prost-reflect",
|
||||
"prost-types",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tonic",
|
||||
"tonic-reflection",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -374,9 +398,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
|
|
@ -431,9 +455,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
|||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
|
|
@ -446,7 +470,6 @@ dependencies = [
|
|||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
|
|
@ -467,13 +490,12 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.19"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
|
|
@ -487,13 +509,21 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -513,21 +543,27 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.180"
|
||||
version = "0.2.185"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
|
|
@ -543,9 +579,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
|||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.6"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
|
|
@ -555,13 +591,13 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
|||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -581,9 +617,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
|
|
@ -619,18 +655,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.10"
|
||||
version = "1.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
|
||||
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.10"
|
||||
version = "1.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
||||
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -639,15 +675,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
|
|
@ -661,9 +691,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.105"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
|
@ -736,9 +766,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.13.0"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0"
|
||||
checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"memchr",
|
||||
|
|
@ -756,24 +786,24 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.43"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.2"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
|
|
@ -783,9 +813,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.13"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
|
|
@ -794,23 +824,29 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.8"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.3"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
|
|
@ -865,9 +901,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.11"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
|
|
@ -877,12 +913,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
|||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.1"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -893,9 +929,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
|||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.114"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -910,15 +946,15 @@ checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
|||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.24.0"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -943,9 +979,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.49.0"
|
||||
version = "1.52.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
|
|
@ -953,14 +989,14 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.0"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -993,9 +1029,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tonic"
|
||||
version = "0.14.2"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203"
|
||||
checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
|
|
@ -1022,9 +1058,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tonic-build"
|
||||
version = "0.14.2"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c40aaccc9f9eccf2cd82ebc111adc13030d23e887244bc9cfa5d1d636049de3"
|
||||
checksum = "1882ac3bf5ef12877d7ed57aad87e75154c11931c2ba7e6cde5e22d63522c734"
|
||||
dependencies = [
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
|
|
@ -1034,9 +1070,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tonic-prost"
|
||||
version = "0.14.2"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67"
|
||||
checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost",
|
||||
|
|
@ -1045,9 +1081,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tonic-prost-build"
|
||||
version = "0.14.2"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4a16cba4043dc3ff43fcb3f96b4c5c154c64cbd18ca8dce2ab2c6a451d058a2"
|
||||
checksum = "f3144df636917574672e93d0f56d7edec49f90305749c668df5101751bb8f95a"
|
||||
dependencies = [
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
|
|
@ -1059,6 +1095,20 @@ dependencies = [
|
|||
"tonic-build",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tonic-reflection"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aaf0685a51e6d02b502ba0764002e766b7f3042aed13d9234925b6ffbfa3fca7"
|
||||
dependencies = [
|
||||
"prost",
|
||||
"prost-types",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tonic",
|
||||
"tonic-prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
|
|
@ -1135,9 +1185,15 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
|||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.22"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
|
|
@ -1162,11 +1218,54 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
|||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.51.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1175,15 +1274,6 @@ version = "0.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
|
|
@ -1193,79 +1283,102 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.15"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
|
|
|||
26
Cargo.toml
26
Cargo.toml
|
|
@ -1,3 +1,27 @@
|
|||
[workspace]
|
||||
members = ["granc", "echo-service"]
|
||||
members = ["granc", "granc-core", "granc-test-support"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
authors = ["Victor Martínez Montané <jaster.victor@gmail.com>"]
|
||||
edition = "2024"
|
||||
homepage = "https://codeberg.org/JasterV/granc"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://codeberg.org/JasterV/granc"
|
||||
rust-version = "1.89"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde_json = "1.0.149"
|
||||
tokio = { version = "1.52.1" }
|
||||
futures-util = "0.3.32"
|
||||
|
||||
# Tonic & prost related deps
|
||||
# They must be updated all at once
|
||||
prost = "0.14"
|
||||
prost-reflect = "0.16.3"
|
||||
prost-types = "0.14"
|
||||
prost-build = "0.14"
|
||||
tonic = "0.14"
|
||||
tonic-prost = "0.14.5"
|
||||
tonic-reflection = "0.14"
|
||||
tonic-prost-build = "0.14"
|
||||
|
|
|
|||
|
|
@ -1,25 +1,22 @@
|
|||
[config]
|
||||
# Sets the default task to run when executing `cargo make` without arguments
|
||||
default_to_workspace = false
|
||||
skip_core_tasks = true
|
||||
|
||||
# --- Main Flow Tasks ---
|
||||
|
||||
[tasks.default]
|
||||
description = "Default task: Formats, Lints, and Builds"
|
||||
dependencies = ["fmt", "clippy", "build"]
|
||||
|
||||
[tasks.ci]
|
||||
description = "CI task: Runs checks without modifying files"
|
||||
dependencies = ["fmt-check", "clippy", "test"]
|
||||
|
||||
# --- Atomic Tasks ---
|
||||
[tasks.run]
|
||||
description = "Runs the CLI tool with dynamic arguments"
|
||||
command = "cargo"
|
||||
# Added '-p granc' to explicitly target the CLI binary in the workspace
|
||||
args = ["run", "-p", "granc", "${@}"]
|
||||
|
||||
[tasks.test]
|
||||
description = "Runs tests for the granc crate only"
|
||||
command = "cargo"
|
||||
args = ["nextest", "run", "--no-fail-fast", "--workspace"]
|
||||
|
||||
[tasks.fmt]
|
||||
description = "Formats all source files"
|
||||
command = "cargo"
|
||||
|
|
@ -43,9 +40,3 @@ args = [
|
|||
"-D",
|
||||
"warnings",
|
||||
]
|
||||
|
||||
[tasks.test]
|
||||
description = "Runs tests for the granc crate only"
|
||||
command = "cargo"
|
||||
# Added '-p granc' to strictly run integration/unit tests for the CLI
|
||||
args = ["nextest", "run", "--no-fail-fast", "-p", "granc"]
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
granc/README.md
|
||||
295
README.md
Normal file
295
README.md
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
# Granc 🦀
|
||||
|
||||
[](https://crates.io/crates/granc)
|
||||
[](https://github.com/JasterV/granc/blob/main/LICENSE)
|
||||
|
||||
**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`.
|
||||
* **Metadata Support**: Easily attach custom headers (authorization, tracing) to your requests.
|
||||
* **Fast Fail Validation**: Validates your JSON *before* hitting the network.
|
||||
* **Smart Dispatch**: Automatically detects if a call is Unary, Server Streaming, Client Streaming, or Bidirectional based on the descriptor.
|
||||
* **Server Reflection**: Can fetch schemas directly from the server, removing the need to pass a local file descriptor set file (`.bin` or `.pb`).
|
||||
* **Introspection Tools**: Commands to list services and describe services, messages, and enums.
|
||||
* **Local Introspection**: In addition to making network requests, Granc can also be used as a local introspection tool for file descriptor binary files. You can load a local `.bin` file to inspect services, messages, and enums without needing to fetch the schema from a server.
|
||||
* **Documentation Generator**: Generate static, cross-linked Markdown documentation for your services and types directly from the schema. [See a real example](./examples/docs/index.md) generated from this repo's [example protos](./examples/proto/library).
|
||||
* **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
|
||||
|
||||
- With `cargo install`:
|
||||
|
||||
```bash
|
||||
cargo install --locked granc
|
||||
```
|
||||
|
||||
- With `cargo binstall`:
|
||||
|
||||
```bash
|
||||
cargo binstall granc
|
||||
```
|
||||
|
||||
(`cargo binstall` will download the compiled binary from the latest release)
|
||||
|
||||
## 🛠️ 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 <COMMAND> [ARGS]
|
||||
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
#### 1. `call` (Make Requests)
|
||||
|
||||
Performs a gRPC call using a JSON body.
|
||||
|
||||
```bash
|
||||
granc call <ENDPOINT> --uri <URI> --body <JSON> [OPTIONS]
|
||||
|
||||
```
|
||||
|
||||
| Argument/Flag | Short | Description | Required |
|
||||
| --- | --- | --- | --- |
|
||||
| `<ENDPOINT>` | | Fully qualified method name (e.g., `my.package.Service/Method`). | **Yes** |
|
||||
| `--uri` | `-u` | Server address (e.g., `http://[::1]:50051`). | **Yes** |
|
||||
| `--body` | `-b` | The request body in JSON format. Object `{}` for unary, Array `[]` for streaming. | **Yes** |
|
||||
| `--header` | `-H` | Custom header `key:value`. Can be used multiple times. | No |
|
||||
| `--file-descriptor-set` | `-f` | Path to a local `.bin` descriptor file to use instead of reflection. | No |
|
||||
|
||||
**Example using Server Reflection:**
|
||||
|
||||
```bash
|
||||
granc call helloworld.Greeter/SayHello --uri http://localhost:50051 --body '{"name": "Ferris"}'
|
||||
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Hello Ferris"
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**Example using a Local Descriptor File:**
|
||||
|
||||
```bash
|
||||
granc call helloworld.Greeter/SayHello \
|
||||
--uri http://localhost:50051 \
|
||||
--file-descriptor-set ./descriptors.bin \
|
||||
--body '{"name": "Ferris"}'
|
||||
|
||||
```
|
||||
|
||||
#### 2. `list` (Service Discovery)
|
||||
|
||||
Lists all services exposed by the server (via reflection) or contained in the provided descriptor file. You must provide **either** a URI or a file descriptor set.
|
||||
|
||||
```bash
|
||||
granc list [OPTIONS]
|
||||
|
||||
```
|
||||
|
||||
| Flag | Short | Description |
|
||||
| --- | --- | --- |
|
||||
| `--uri` | `-u` | Use Server Reflection to list available services. |
|
||||
| `--file-descriptor-set` | `-f` | Use a local file to list contained services (offline). |
|
||||
|
||||
**Listing services via Reflection:**
|
||||
|
||||
```bash
|
||||
granc list --uri http://localhost:50051
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
Available Services:
|
||||
- grpc.reflection.v1.ServerReflection
|
||||
- helloworld.Greeter
|
||||
|
||||
```
|
||||
|
||||
**Listing services from a file (Offline):**
|
||||
|
||||
```bash
|
||||
granc list --file-descriptor-set ./descriptors.bin
|
||||
|
||||
```
|
||||
|
||||
#### 3. `describe` (Introspection)
|
||||
|
||||
Inspects a specific symbol (Service, Message, or Enum) and prints its Protobuf definition in a colored, human-readable format. You must provide **either** a URI or a file descriptor set.
|
||||
|
||||
```bash
|
||||
granc describe <SYMBOL> [OPTIONS]
|
||||
|
||||
```
|
||||
|
||||
| Argument/Flag | Short | Description |
|
||||
| --- | --- | --- |
|
||||
| `<SYMBOL>` | | Fully qualified name of the Service, Message, or Enum. |
|
||||
| `--uri` | `-u` | Use Server Reflection to resolve the symbol. |
|
||||
| `--file-descriptor-set` | `-f` | Use a local file to resolve the symbol (offline). |
|
||||
|
||||
**Describing a Service via Reflection:**
|
||||
|
||||
```bash
|
||||
granc describe helloworld.Greeter --uri http://localhost:50051
|
||||
|
||||
```
|
||||
|
||||
```proto
|
||||
service Greeter {
|
||||
rpc SayHello(helloworld.HelloRequest) returns (helloworld.HelloReply);
|
||||
rpc StreamHello(stream helloworld.HelloRequest) returns (stream helloworld.HelloReply);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**Describing a Message using a Local File:**
|
||||
|
||||
```bash
|
||||
granc describe helloworld.HelloRequest --file-descriptor-set ./descriptors.bin
|
||||
|
||||
```
|
||||
|
||||
```proto
|
||||
message HelloRequest {
|
||||
string name = 1;
|
||||
int32 age = 2;
|
||||
repeated string tags = 3;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**Describing an Enum:**
|
||||
|
||||
```bash
|
||||
granc describe my.package.Status --uri http://localhost:50051
|
||||
|
||||
```
|
||||
|
||||
```proto
|
||||
enum Status {
|
||||
UNKNOWN = 0;
|
||||
ACTIVE = 1;
|
||||
INACTIVE = 2;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
#### 4. `doc` (Documentation Generator)
|
||||
|
||||
Generates static Markdown documentation for a specific service and its dependencies. This is useful for creating browseable documentation for your gRPC APIs.
|
||||
|
||||
```bash
|
||||
granc doc <SYMBOL> --output <DIR> [OPTIONS]
|
||||
```
|
||||
|
||||
| Argument/Flag | Short | Description |
|
||||
| --- | --- | --- |
|
||||
| `<SYMBOL>` | | Fully qualified name of the Service (e.g., `library.LibraryService`). |
|
||||
| `--output` | `-o` | Directory where the markdown files will be generated. |
|
||||
| `--uri` | `-u` | Use Server Reflection to resolve the schema. |
|
||||
| `--file-descriptor-set` | `-f` | Use a local file to resolve the schema (offline). |
|
||||
|
||||
**Generating docs via Reflection:**
|
||||
|
||||
```bash
|
||||
granc doc helloworld.Greeter --uri http://localhost:50051 --output ./docs
|
||||
```
|
||||
|
||||
**Generating docs from a file:**
|
||||
|
||||
```bash
|
||||
granc doc library.LibraryService --file-descriptor-set examples/library.bin --output ./docs
|
||||
```
|
||||
|
||||
Check out the full [generated documentation example](./examples/docs/index.md) included in this repository.
|
||||
These documents were generated directly from the [library example protos](./examples/proto/library) using the command above.
|
||||
|
||||
## 🔮 Roadmap
|
||||
|
||||
* **Interactive Mode**: A REPL for streaming requests interactively.
|
||||
* **Pretty Printing JSON**: Enhanced colored output for JSON responses.
|
||||
* **TLS Support**: Configurable root certificates and client identity.
|
||||
|
||||
## 🧩 Using as a Library
|
||||
|
||||
The core logic of Granc is decoupled into a separate library crate, **`granc-core`**.
|
||||
|
||||
If you want to build your own tools using the dynamic gRPC engine (e.g., for custom integration testing, proxies, or automation tools), you can depend on `granc-core` directly.
|
||||
|
||||
* **Documentation & Usage**: See the **[`granc-core` README](./granc-core/README.md)** for examples on how to use the `GrancClient` programmatically.
|
||||
* **Crate**: [`granc-core`](https://crates.io/crates/granc_core)
|
||||
|
||||
## ⚠️ 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,14 +0,0 @@
|
|||
[package]
|
||||
name = "echo-service"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
bytes = "1"
|
||||
prost = "0.14"
|
||||
tonic = "0.14"
|
||||
prost-types = "0.14"
|
||||
tonic-prost = "0.14.2"
|
||||
|
||||
[build-dependencies]
|
||||
tonic-prost-build = "0.14"
|
||||
12
examples/docs/index.md
Normal file
12
examples/docs/index.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Documentation Index
|
||||
|
||||
|
||||
## Service
|
||||
|
||||
- [**LibraryService**](library.md#LibraryService)
|
||||
|
||||
## Packages
|
||||
|
||||
- [library](library.md)
|
||||
- [library.domain](library.domain.md)
|
||||
- [library.rpc](library.rpc.md)
|
||||
86
examples/docs/library.domain.md
Normal file
86
examples/docs/library.domain.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<a id="Author"></a>
|
||||
## Author
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.domain;
|
||||
|
||||
message Author {
|
||||
string id = 1;
|
||||
string full_name = 2;
|
||||
repeated library.domain.Book bibliography = 3;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Field `bibliography`: [Book](library.domain.md#Book)
|
||||
|
||||
---
|
||||
|
||||
<a id="Book"></a>
|
||||
## Book
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.domain;
|
||||
|
||||
message Book {
|
||||
string isbn = 1;
|
||||
string title = 2;
|
||||
library.domain.Author author = 3;
|
||||
library.domain.Publisher publisher = 4;
|
||||
library.domain.Genre genre = 5;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Field `author`: [Author](library.domain.md#Author)
|
||||
- Field `publisher`: [Publisher](library.domain.md#Publisher)
|
||||
- Field `genre`: [Genre](library.domain.md#Genre)
|
||||
|
||||
---
|
||||
|
||||
<a id="Publisher"></a>
|
||||
## Publisher
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.domain;
|
||||
|
||||
message Publisher {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string address = 3;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
*None*
|
||||
|
||||
---
|
||||
|
||||
<a id="Genre"></a>
|
||||
## Genre
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.domain;
|
||||
|
||||
enum Genre {
|
||||
UNKNOWN = 0;
|
||||
FICTION = 1;
|
||||
NON_FICTION = 2;
|
||||
SCI_FI = 3;
|
||||
HISTORY = 4;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
44
examples/docs/library.md
Normal file
44
examples/docs/library.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<a id="LibraryService"></a>
|
||||
## LibraryService
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library;
|
||||
|
||||
service LibraryService {
|
||||
rpc GetBook(library.rpc.GetBookRequest) returns (library.domain.Book);
|
||||
|
||||
rpc QueryBooks(library.rpc.QueryBooksRequest) returns (stream library.domain.Book);
|
||||
|
||||
rpc Checkout(stream library.rpc.CheckoutRequest) returns (library.rpc.CheckoutResponse);
|
||||
|
||||
rpc SupportChat(stream library.rpc.ChatMessage) returns (stream library.rpc.ChatMessage);
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
#### `GetBook`
|
||||
|
||||
- Request: [GetBookRequest](library.rpc.md#GetBookRequest)
|
||||
- Response: [Book](library.domain.md#Book)
|
||||
|
||||
#### `QueryBooks`
|
||||
|
||||
- Request: [QueryBooksRequest](library.rpc.md#QueryBooksRequest)
|
||||
- Response: [Book](library.domain.md#Book)
|
||||
|
||||
#### `Checkout`
|
||||
|
||||
- Request: [CheckoutRequest](library.rpc.md#CheckoutRequest)
|
||||
- Response: [CheckoutResponse](library.rpc.md#CheckoutResponse)
|
||||
|
||||
#### `SupportChat`
|
||||
|
||||
- Request: [ChatMessage](library.rpc.md#ChatMessage)
|
||||
- Response: [ChatMessage](library.rpc.md#ChatMessage)
|
||||
|
||||
---
|
||||
|
||||
100
examples/docs/library.rpc.md
Normal file
100
examples/docs/library.rpc.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<a id="ChatMessage"></a>
|
||||
## ChatMessage
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.rpc;
|
||||
|
||||
message ChatMessage {
|
||||
string user_id = 1;
|
||||
string text = 2;
|
||||
int64 timestamp = 3;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
*None*
|
||||
|
||||
---
|
||||
|
||||
<a id="CheckoutRequest"></a>
|
||||
## CheckoutRequest
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.rpc;
|
||||
|
||||
message CheckoutRequest {
|
||||
string isbn = 1;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
*None*
|
||||
|
||||
---
|
||||
|
||||
<a id="CheckoutResponse"></a>
|
||||
## CheckoutResponse
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.rpc;
|
||||
|
||||
message CheckoutResponse {
|
||||
repeated library.domain.Book checked_out_books = 1;
|
||||
int32 total_items = 2;
|
||||
string due_date = 3;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Field `checked_out_books`: [Book](library.domain.md#Book)
|
||||
|
||||
---
|
||||
|
||||
<a id="GetBookRequest"></a>
|
||||
## GetBookRequest
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.rpc;
|
||||
|
||||
message GetBookRequest {
|
||||
string isbn = 1;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
*None*
|
||||
|
||||
---
|
||||
|
||||
<a id="QueryBooksRequest"></a>
|
||||
## QueryBooksRequest
|
||||
|
||||
### Definition
|
||||
|
||||
```protobuf
|
||||
package library.rpc;
|
||||
|
||||
message QueryBooksRequest {
|
||||
string title_prefix = 1;
|
||||
library.domain.Genre genre_filter = 2;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Field `genre_filter`: [Genre](library.domain.md#Genre)
|
||||
|
||||
---
|
||||
|
||||
38
examples/proto/library/domain.proto
Normal file
38
examples/proto/library/domain.proto
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package library.domain;
|
||||
|
||||
enum Genre {
|
||||
UNKNOWN = 0;
|
||||
FICTION = 1;
|
||||
NON_FICTION = 2;
|
||||
SCI_FI = 3;
|
||||
HISTORY = 4;
|
||||
}
|
||||
|
||||
message Publisher {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string address = 3;
|
||||
}
|
||||
|
||||
// Represents a Book in the collection.
|
||||
message Book {
|
||||
string isbn = 1;
|
||||
string title = 2;
|
||||
|
||||
// Circular Dependency: Book references Author (valid since they are in the same file)
|
||||
Author author = 3;
|
||||
|
||||
Publisher publisher = 4;
|
||||
|
||||
Genre genre = 5;
|
||||
}
|
||||
|
||||
message Author {
|
||||
string id = 1;
|
||||
string full_name = 2;
|
||||
|
||||
// Circular Dependency: Author references Book
|
||||
repeated Book bibliography = 3;
|
||||
}
|
||||
32
examples/proto/library/rpc.proto
Normal file
32
examples/proto/library/rpc.proto
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package library.rpc;
|
||||
|
||||
import "library/domain.proto";
|
||||
|
||||
message GetBookRequest {
|
||||
string isbn = 1;
|
||||
}
|
||||
|
||||
message QueryBooksRequest {
|
||||
string title_prefix = 1;
|
||||
// Reusing the Genre enum from domain.proto
|
||||
library.domain.Genre genre_filter = 2;
|
||||
}
|
||||
|
||||
message CheckoutRequest {
|
||||
string isbn = 1;
|
||||
}
|
||||
|
||||
message CheckoutResponse {
|
||||
// Reusing Book type
|
||||
repeated library.domain.Book checked_out_books = 1;
|
||||
int32 total_items = 2;
|
||||
string due_date = 3;
|
||||
}
|
||||
|
||||
message ChatMessage {
|
||||
string user_id = 1;
|
||||
string text = 2;
|
||||
int64 timestamp = 3;
|
||||
}
|
||||
21
examples/proto/library/service.proto
Normal file
21
examples/proto/library/service.proto
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package library;
|
||||
|
||||
import "library/domain.proto";
|
||||
import "library/rpc.proto";
|
||||
|
||||
// Service for managing a collection of books and authors.
|
||||
service LibraryService {
|
||||
// Unary
|
||||
rpc GetBook(library.rpc.GetBookRequest) returns (library.domain.Book);
|
||||
|
||||
// Server Streaming
|
||||
rpc QueryBooks(library.rpc.QueryBooksRequest) returns (stream library.domain.Book);
|
||||
|
||||
// Client Streaming
|
||||
rpc Checkout(stream library.rpc.CheckoutRequest) returns (library.rpc.CheckoutResponse);
|
||||
|
||||
// Bidirectional
|
||||
rpc SupportChat(stream library.rpc.ChatMessage) returns (stream library.rpc.ChatMessage);
|
||||
}
|
||||
36
granc-core/Cargo.toml
Normal file
36
granc-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
[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 = "0.6.4"
|
||||
|
||||
[lib]
|
||||
name = "granc_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
futures-util = { workspace = true }
|
||||
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-reflection = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
granc-test-support = { path = "../granc-test-support" }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
124
granc-core/README.md
Normal file
124
granc-core/README.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# 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.
|
||||
|
||||
## 🚀 High-Level Usage
|
||||
|
||||
The primary entry point is the [`GrancClient`]. It uses a **Typestate Pattern** to ensure safety and correctness regarding how the Protobuf schema is resolved. There are three distinct states:
|
||||
|
||||
1. **[`Online`]**: Connected to a server, uses Server Reflection (Async introspection).
|
||||
2. **[`OnlineWithoutReflection`]**: Connected to a server, uses a local `FileDescriptorSet` (Sync introspection).
|
||||
3. **[`Offline`]**: Disconnected, uses a local `FileDescriptorSet` (Sync introspection).
|
||||
|
||||
### 1. Online (Server Reflection)
|
||||
|
||||
This is the default state when you connect. The client queries the server's reflection endpoint to dynamically discover services and message formats.
|
||||
|
||||
```rust
|
||||
use granc_core::client::{GrancClient, DynamicRequest, DynamicResponse};
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 1. Connect (Starts in 'Online' state)
|
||||
let mut client = GrancClient::connect("http://localhost:50051").await?;
|
||||
|
||||
// 2. Introspection (Async via Reflection)
|
||||
let services = client.list_services().await?;
|
||||
println!("Server services: {:?}", services);
|
||||
|
||||
// 3. Dynamic Call
|
||||
let request = DynamicRequest {
|
||||
service: "helloworld.Greeter".to_string(),
|
||||
method: "SayHello".to_string(),
|
||||
body: json!({ "name": "Ferris" }),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
// Schema is fetched automatically from the server
|
||||
let response = client.dynamic(request).await?;
|
||||
println!("{:?}", response);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 2. OnlineWithoutReflection (Local Schema)
|
||||
|
||||
Use this state if you are connecting to a server that does not support reflection, or if you want to enforce a specific schema version from a local file.
|
||||
|
||||
```rust
|
||||
use granc_core::client::GrancClient;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let client = GrancClient::connect("http://localhost:50051").await?;
|
||||
let descriptor_bytes = std::fs::read("descriptor.bin")?;
|
||||
|
||||
// Transition state: Online -> OnlineWithoutReflection
|
||||
let mut client = client.with_file_descriptor(descriptor_bytes)?;
|
||||
|
||||
// Introspection is now SYNCHRONOUS (in-memory)
|
||||
let services = client.list_services();
|
||||
println!("Local services: {:?}", services);
|
||||
|
||||
// Dynamic calls use the local schema to encode/decode
|
||||
// client.dynamic(req).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 3. Offline (Introspection Only)
|
||||
|
||||
This state is useful for building tools that need to inspect `.bin` descriptor files without establishing a network connection.
|
||||
|
||||
```rust
|
||||
use granc_core::client::GrancClient;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let descriptor_bytes = std::fs::read("descriptor.bin")?;
|
||||
|
||||
// Create directly in 'Offline' state
|
||||
let client = GrancClient::offline(descriptor_bytes)?;
|
||||
|
||||
// Sync introspection methods
|
||||
let services = client.list_services();
|
||||
|
||||
if let Some(descriptor) = client.get_descriptor_by_symbol("helloworld.Greeter") {
|
||||
println!("Found service: {:?}", descriptor);
|
||||
}
|
||||
|
||||
// Note: client.dynamic() is NOT available in this state.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 🛠️ 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. `GrpcClient` is a generic wrapper around `tonic::client::Grpc` that works strictly with `serde_json::Value` and `prost_reflect::MethodDescriptor`. It handles the raw HTTP/2 path construction and metadata mapping.
|
||||
|
||||
### 2. `JsonCodec`
|
||||
|
||||
The magic behind the dynamic serialization. This implementation of `tonic::codec::Codec` validates and transcodes JSON to Protobuf bytes (and vice versa) on the fly.
|
||||
|
||||
### 3. `ReflectionClient`
|
||||
|
||||
A robust client for `grpc.reflection.v1`. It automatically handles transitive dependency resolution, recursively fetching all imported files to build a complete, self-contained `FileDescriptorSet`.
|
||||
|
||||
## ⚖️ License
|
||||
|
||||
Licensed under either of Apache License, Version 2.0 or MIT license at your option.
|
||||
107
granc-core/src/client.rs
Normal file
107
granc-core/src/client.rs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
//! # Granc Client
|
||||
//!
|
||||
//! This module implements the high-level logic for executing dynamic gRPC requests.
|
||||
//!
|
||||
//! The [`GrancClient`] uses a **Typestate Pattern** to ensure safety and correctness regarding
|
||||
//! how the Protobuf schema is resolved. It has three possible states:
|
||||
//!
|
||||
//! 1. **[`Online`]**: The default state when connecting. The client uses the gRPC
|
||||
//! Server Reflection Protocol (`grpc.reflection.v1`) to discover services.
|
||||
//! 2. **[`OnlineWithoutReflection`]**: The client is connected to a server but uses a local
|
||||
//! binary `FileDescriptorSet` for schema lookups.
|
||||
//! 3. **[`Offline`]**: The client is **not connected** to any server. It holds a
|
||||
//! local `FileDescriptorSet` and can only be used for introspection (Listing services, describing symbols),
|
||||
//! but cannot perform gRPC calls.
|
||||
//!
|
||||
//! ## Example: State Transition
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use granc_core::client::GrancClient;
|
||||
//!
|
||||
//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! // 1. Online State (Reflection)
|
||||
//! let mut client = GrancClient::connect("http://localhost:50051").await?;
|
||||
//!
|
||||
//! // 2. Transition to OnlineWithoutReflection (Connected + Local Schema)
|
||||
//! let bytes = std::fs::read("descriptor.bin")?;
|
||||
//! let mut client_static = client.with_file_descriptor(bytes)?;
|
||||
//!
|
||||
//! // 3. Offline State (Disconnected + Local Schema)
|
||||
//! let bytes = std::fs::read("descriptor.bin")?;
|
||||
//! let mut client_offline = GrancClient::offline(bytes)?;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
pub mod offline;
|
||||
pub mod online;
|
||||
pub mod online_without_reflection;
|
||||
mod types;
|
||||
|
||||
pub use types::*;
|
||||
|
||||
use crate::{grpc::client::GrpcClient, reflection::client::ReflectionClient};
|
||||
use prost_reflect::DescriptorPool;
|
||||
use std::fmt::Debug;
|
||||
use tonic::transport::Channel;
|
||||
|
||||
/// The main client for interacting with gRPC servers dynamically.
|
||||
///
|
||||
/// The generic parameter `T` represents the current state of the client.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GrancClient<T> {
|
||||
state: T,
|
||||
}
|
||||
|
||||
impl<T> GrancClient<T> {
|
||||
pub(crate) fn new(state: T) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
/// State: Connected to server, Schema resolved from Server Reflection.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Online<S = Channel> {
|
||||
reflection_client: ReflectionClient<S>,
|
||||
grpc_client: GrpcClient<S>,
|
||||
}
|
||||
|
||||
/// State: Connected to server, Schema resolved from local FileDescriptor.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OnlineWithoutReflection<S = Channel> {
|
||||
grpc_client: GrpcClient<S>,
|
||||
pool: DescriptorPool,
|
||||
}
|
||||
|
||||
impl<S> OnlineWithoutReflection<S> {
|
||||
pub(crate) fn new(grpc_client: GrpcClient<S>, pool: DescriptorPool) -> Self {
|
||||
Self { pool, grpc_client }
|
||||
}
|
||||
}
|
||||
|
||||
/// State: Disconnected, Schema resolved from local FileDescriptor.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Offline {
|
||||
pool: DescriptorPool,
|
||||
}
|
||||
|
||||
impl Offline {
|
||||
pub(crate) fn new(pool: DescriptorPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
pub trait OfflineReflectionState {
|
||||
fn descriptor_pool(&self) -> &DescriptorPool;
|
||||
}
|
||||
|
||||
impl OfflineReflectionState for Offline {
|
||||
fn descriptor_pool(&self) -> &DescriptorPool {
|
||||
&self.pool
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> OfflineReflectionState for OnlineWithoutReflection<S> {
|
||||
fn descriptor_pool(&self) -> &DescriptorPool {
|
||||
&self.pool
|
||||
}
|
||||
}
|
||||
73
granc-core/src/client/offline.rs
Normal file
73
granc-core/src/client/offline.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
//! # Client State: Offline
|
||||
//!
|
||||
//! This module defines the `GrancClient` behavior when it is using a local, in-memory
|
||||
//! `DescriptorPool` but is **not connected** to any gRPC server.
|
||||
//!
|
||||
//! In this state, the client is strictly limited to introspection tasks.
|
||||
use super::{GrancClient, Offline};
|
||||
use crate::client::{OfflineReflectionState, types::Descriptor};
|
||||
use prost_reflect::{DescriptorError, DescriptorPool};
|
||||
|
||||
impl GrancClient<Offline> {
|
||||
/// Creates a new `GrancClient` in the Offline state using a raw byte buffer
|
||||
/// containing a `FileDescriptorSet`.
|
||||
///
|
||||
/// This client starts in a **disconnected** state. It can be used to inspect the
|
||||
/// provided schema but cannot make network requests.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `file_descriptor` - A vector of bytes containing the encoded `FileDescriptorSet`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(GrancClient<Offline>)` - The initialized offline client.
|
||||
/// * `Err(DescriptorError)` - If the bytes are not a valid descriptor set.
|
||||
pub fn offline(file_descriptor: Vec<u8>) -> Result<Self, DescriptorError> {
|
||||
let pool = DescriptorPool::decode(file_descriptor.as_slice())?;
|
||||
Ok(GrancClient::new(Offline::new(pool)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> GrancClient<T>
|
||||
where
|
||||
T: OfflineReflectionState,
|
||||
{
|
||||
/// Lists all services defined in the local `DescriptorPool`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A list of fully qualified service names (e.g. `helloworld.Greeter`).
|
||||
pub fn list_services(&self) -> Vec<String> {
|
||||
self.state
|
||||
.descriptor_pool()
|
||||
.services()
|
||||
.map(|s| s.full_name().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Looks up a specific symbol in the local `DescriptorPool`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `symbol` - The fully qualified name (Service, Message, or Enum).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Some(Descriptor)` - The resolved descriptor if found.
|
||||
/// * `None` - If the symbol does not exist in the pool.
|
||||
pub fn get_descriptor_by_symbol(&self, symbol: &str) -> Option<Descriptor> {
|
||||
let pool = self.state.descriptor_pool();
|
||||
|
||||
if let Some(descriptor) = pool.get_service_by_name(symbol) {
|
||||
return Some(Descriptor::ServiceDescriptor(descriptor));
|
||||
}
|
||||
if let Some(descriptor) = pool.get_message_by_name(symbol) {
|
||||
return Some(Descriptor::MessageDescriptor(descriptor));
|
||||
}
|
||||
if let Some(descriptor) = pool.get_enum_by_name(symbol) {
|
||||
return Some(Descriptor::EnumDescriptor(descriptor));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
224
granc-core/src/client/online.rs
Normal file
224
granc-core/src/client/online.rs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
//! # Client State: Online (With Server Reflection)
|
||||
//!
|
||||
//! This module defines the `GrancClient` behavior when it is connected to a server
|
||||
//! and using Server Reflection for schema resolution.
|
||||
use super::{
|
||||
Descriptor, DynamicRequest, DynamicResponse, GrancClient, Online, OnlineWithoutReflection,
|
||||
};
|
||||
use crate::{
|
||||
BoxError,
|
||||
client::Offline,
|
||||
grpc::client::GrpcClient,
|
||||
reflection::client::{ReflectionClient, ReflectionResolveError},
|
||||
};
|
||||
use http_body::Body as HttpBody;
|
||||
use prost_reflect::{DescriptorError, DescriptorPool};
|
||||
use std::fmt::Debug;
|
||||
use tonic::{
|
||||
Code,
|
||||
transport::{Channel, Endpoint},
|
||||
};
|
||||
|
||||
/// Errors that can occur when connecting to a gRPC server.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ClientConnectError {
|
||||
#[error("Invalid URI '{0}': {1}")]
|
||||
InvalidUri(String, #[source] tonic::transport::Error),
|
||||
#[error("Failed to connect to '{0}': {1}")]
|
||||
ConnectionFailed(String, #[source] tonic::transport::Error),
|
||||
}
|
||||
|
||||
/// Errors that can occur during a dynamic call in Online mode.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DynamicCallError {
|
||||
#[error("Reflection resolution failed: '{0}'")]
|
||||
ReflectionResolve(#[from] ReflectionResolveError),
|
||||
#[error("Failed to decode file descriptor set: '{0}'")]
|
||||
DescriptorError(#[from] DescriptorError),
|
||||
#[error(transparent)]
|
||||
DynamicCallError(#[from] super::online_without_reflection::DynamicCallError),
|
||||
}
|
||||
|
||||
/// Errors that can occur when looking up a descriptor in Online mode.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GetDescriptorError {
|
||||
#[error("Reflection resolution failed: '{0}'")]
|
||||
ReflectionResolve(#[from] ReflectionResolveError),
|
||||
#[error("Failed to decode file descriptor set: '{0}'")]
|
||||
DescriptorError(#[from] DescriptorError),
|
||||
#[error("Descriptor at path '{0}' not found")]
|
||||
NotFound(String),
|
||||
}
|
||||
|
||||
impl GrancClient<Online<Channel>> {
|
||||
/// Connects to a gRPC server and initializes the client in the `Online` state.
|
||||
///
|
||||
/// This is the entry point for interacting with a server. By default, the client assumes
|
||||
/// the server supports the gRPC Server Reflection Protocol.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `addr` - The server URI (e.g., `http://localhost:50051`).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(GrancClient<Online>)` - A connected client ready to make dynamic requests via reflection.
|
||||
/// * `Err(ClientConnectError)` - If the URI is invalid or the TCP connection cannot be established.
|
||||
pub async fn connect(addr: &str) -> Result<Self, ClientConnectError> {
|
||||
let endpoint = Endpoint::new(addr.to_string())
|
||||
.map_err(|e| ClientConnectError::InvalidUri(addr.to_string(), e))?;
|
||||
|
||||
let channel = endpoint
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| ClientConnectError::ConnectionFailed(addr.to_string(), e))?;
|
||||
|
||||
Ok(GrancClient::from(channel))
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> From<S> for GrancClient<Online<S>>
|
||||
where
|
||||
S: tonic::client::GrpcService<tonic::body::Body> + Clone,
|
||||
S::ResponseBody: HttpBody<Data = tonic::codegen::Bytes> + Send + 'static,
|
||||
<S::ResponseBody as HttpBody>::Error: Into<BoxError> + Send,
|
||||
{
|
||||
fn from(service: S) -> Self {
|
||||
let reflection_client = ReflectionClient::new(service.clone());
|
||||
let grpc_client = GrpcClient::new(service);
|
||||
Self {
|
||||
state: Online {
|
||||
reflection_client,
|
||||
grpc_client,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> GrancClient<Online<S>>
|
||||
where
|
||||
S: tonic::client::GrpcService<tonic::body::Body> + Clone,
|
||||
S::ResponseBody: HttpBody<Data = tonic::codegen::Bytes> + Send + 'static,
|
||||
<S::ResponseBody as HttpBody>::Error: Into<BoxError> + Send,
|
||||
{
|
||||
/// Transitions the client to the **OnlineWithoutReflection** state by loading a local descriptor.
|
||||
///
|
||||
/// This methods consumes the current client and returns a new one that:
|
||||
/// 1. Retains the existing network connection.
|
||||
/// 2. Disables Server Reflection lookups.
|
||||
/// 3. Uses the provided `FileDescriptorSet` for all future schema resolutions.
|
||||
///
|
||||
/// This is useful when the server does not support reflection or when you want to enforce
|
||||
/// a specific schema version.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `file_descriptor` - A vector of bytes containing the encoded `FileDescriptorSet` (protobuf binary format).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(GrancClient<OnlineWithoutReflection>)` - The client in the new state.
|
||||
/// * `Err(DescriptorError)` - If the provided bytes cannot be decoded into a valid descriptor pool.
|
||||
pub fn with_file_descriptor(
|
||||
self,
|
||||
file_descriptor: Vec<u8>,
|
||||
) -> Result<GrancClient<OnlineWithoutReflection<S>>, DescriptorError> {
|
||||
let pool = DescriptorPool::decode(file_descriptor.as_slice())?;
|
||||
|
||||
Ok(GrancClient::new(OnlineWithoutReflection::new(
|
||||
self.state.grpc_client,
|
||||
pool,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Lists all services exposed by the server using the Reflection Protocol.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<String>)` - A list of fully qualified service names (e.g., `helloworld.Greeter`).
|
||||
/// * `Err(ReflectionResolveError)` - If the reflection call fails, the stream is closed unexpectedly,
|
||||
/// or the server returns an error code.
|
||||
pub async fn list_services(&mut self) -> Result<Vec<String>, ReflectionResolveError> {
|
||||
self.state.reflection_client.list_services().await
|
||||
}
|
||||
|
||||
/// Resolves and fetches the descriptor for a specific symbol using Reflection.
|
||||
///
|
||||
/// This will query the server for the symbol, fetch the defining file, and recursively fetch
|
||||
/// all imported dependencies to build a complete `Descriptor`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `symbol` - The fully qualified name of the symbol (Service, Message, or Enum).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Descriptor)` - The resolved descriptor wrapper.
|
||||
/// * `Err(GetDescriptorError)` - If the symbol is not found on the server or the reflection request fails.
|
||||
pub async fn get_descriptor_by_symbol(
|
||||
&mut self,
|
||||
symbol: &str,
|
||||
) -> Result<Descriptor, GetDescriptorError> {
|
||||
let fd_set = self
|
||||
.state
|
||||
.reflection_client
|
||||
.file_descriptor_set_by_symbol(symbol)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
ReflectionResolveError::ServerStreamFailure(status)
|
||||
if status.code() == Code::NotFound =>
|
||||
{
|
||||
GetDescriptorError::NotFound(symbol.to_string())
|
||||
}
|
||||
err => GetDescriptorError::ReflectionResolve(err),
|
||||
})?;
|
||||
|
||||
let pool = DescriptorPool::from_file_descriptor_set(fd_set)?;
|
||||
let client = GrancClient::new(Offline::new(pool));
|
||||
|
||||
client
|
||||
.get_descriptor_by_symbol(symbol)
|
||||
.ok_or_else(|| GetDescriptorError::NotFound(symbol.to_string()))
|
||||
}
|
||||
|
||||
/// Executes a dynamic gRPC request using Server Reflection for schema resolution.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `request` - A [`DynamicRequest`] struct containing:
|
||||
/// - `service`: The fully qualified name of the service (e.g., `my.package.MyService`).
|
||||
/// - `method`: The name of the method to call (e.g., `MyMethod`).
|
||||
/// - `body`: The JSON payload (Object for Unary/ServerStreaming, Array for Client/BiDi Streaming).
|
||||
/// - `headers`: Optional gRPC metadata/headers.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(DynamicResponse)` - The result of the call, which can be:
|
||||
/// - [`DynamicResponse::Unary`]: For Unary and Client Streaming calls (single response).
|
||||
/// - [`DynamicResponse::Streaming`]: For Server Streaming and Bidirectional calls (stream of responses).
|
||||
/// * `Err(DynamicCallError)` - If an error occurs during:
|
||||
/// - Reflection resolution (e.g., Service not found).
|
||||
/// - Schema parsing.
|
||||
/// - Request serialization (JSON to Proto).
|
||||
/// - Network transport.
|
||||
/// - Response deserialization.
|
||||
pub async fn dynamic(
|
||||
&mut self,
|
||||
request: DynamicRequest,
|
||||
) -> Result<DynamicResponse, DynamicCallError> {
|
||||
let fd_set = self
|
||||
.state
|
||||
.reflection_client
|
||||
.file_descriptor_set_by_symbol(&request.service)
|
||||
.await?;
|
||||
|
||||
let pool = DescriptorPool::from_file_descriptor_set(fd_set)?;
|
||||
|
||||
let mut client = GrancClient::new(OnlineWithoutReflection::new(
|
||||
self.state.grpc_client.clone(),
|
||||
pool,
|
||||
));
|
||||
|
||||
Ok(client.dynamic(request).await?)
|
||||
}
|
||||
}
|
||||
120
granc-core/src/client/online_without_reflection.rs
Normal file
120
granc-core/src/client/online_without_reflection.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
//! # Client State: Online Without Reflection
|
||||
//!
|
||||
//! This module defines the `GrancClient` behavior when it is connected to a server
|
||||
//! but uses a local, in-memory `DescriptorPool` (Static schema) to resolve messages.
|
||||
use super::{DynamicRequest, DynamicResponse, GrancClient, OnlineWithoutReflection};
|
||||
use crate::{BoxError, client::OfflineReflectionState, grpc::client::GrpcRequestError};
|
||||
use futures_util::{Stream, StreamExt, stream};
|
||||
use http_body::Body as HttpBody;
|
||||
use std::fmt::Debug;
|
||||
|
||||
/// Errors that can occur during a dynamic call in OnlineWithoutReflection mode.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DynamicCallError {
|
||||
#[error("Invalid input: '{0}'")]
|
||||
InvalidInput(String),
|
||||
#[error("Service '{0}' not found")]
|
||||
ServiceNotFound(String),
|
||||
#[error("Method '{0}' not found")]
|
||||
MethodNotFound(String),
|
||||
#[error("gRPC client request error: '{0}'")]
|
||||
GrpcRequestError(#[from] GrpcRequestError),
|
||||
}
|
||||
|
||||
impl<S> GrancClient<OnlineWithoutReflection<S>>
|
||||
where
|
||||
S: tonic::client::GrpcService<tonic::body::Body> + Clone,
|
||||
S::ResponseBody: HttpBody<Data = tonic::codegen::Bytes> + Send + 'static,
|
||||
<S::ResponseBody as HttpBody>::Error: Into<BoxError> + Send,
|
||||
{
|
||||
/// Executes a dynamic gRPC request using the locally loaded `FileDescriptorSet`.
|
||||
///
|
||||
/// Unlike the `Online` state, this method does **not** make any calls to the server's reflection endpoint.
|
||||
/// It relies entirely on the local `DescriptorPool` provided during state transition (see [`super::Online::with_file_descriptor`]).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `request` - A [`DynamicRequest`] struct containing:
|
||||
/// - `service`: The fully qualified name of the service (e.g., `my.package.MyService`).
|
||||
/// - `method`: The name of the method to call (e.g., `MyMethod`).
|
||||
/// - `body`: The JSON payload.
|
||||
/// - `headers`: Optional gRPC metadata.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(DynamicResponse)` - The result of the call (Unary or Streaming).
|
||||
/// * `Err(DynamicCallError)` - If validation fails or the network call errors. Specific errors include:
|
||||
/// - [`DynamicCallError::ServiceNotFound`]: The service is not present in the local descriptor.
|
||||
/// - [`DynamicCallError::MethodNotFound`]: The method does not exist in the service.
|
||||
/// - [`DynamicCallError::InvalidInput`]: The JSON body structure is invalid for the streaming mode (e.g. object provided for streaming call).
|
||||
/// - [`DynamicCallError::GrpcRequestError`]: Transport-level errors (connection failed, timeout, etc).
|
||||
pub async fn dynamic(
|
||||
&mut self,
|
||||
request: DynamicRequest,
|
||||
) -> Result<DynamicResponse, DynamicCallError> {
|
||||
let method = self
|
||||
.state
|
||||
.descriptor_pool()
|
||||
.get_service_by_name(&request.service)
|
||||
.ok_or_else(|| DynamicCallError::ServiceNotFound(request.service.clone()))?
|
||||
.methods()
|
||||
.find(|m| m.name() == request.method)
|
||||
.ok_or_else(|| DynamicCallError::MethodNotFound(request.method.clone()))?;
|
||||
|
||||
match (method.is_client_streaming(), method.is_server_streaming()) {
|
||||
(false, false) => {
|
||||
let result = self
|
||||
.state
|
||||
.grpc_client
|
||||
.unary(method, request.body, request.headers)
|
||||
.await?;
|
||||
Ok(DynamicResponse::Unary(result))
|
||||
}
|
||||
(false, true) => match self
|
||||
.state
|
||||
.grpc_client
|
||||
.server_streaming(method, request.body, request.headers)
|
||||
.await?
|
||||
{
|
||||
Ok(stream) => Ok(DynamicResponse::Streaming(stream.boxed())),
|
||||
Err(status) => Ok(DynamicResponse::Streaming(
|
||||
stream::once(async { Err(status) }).boxed(),
|
||||
)),
|
||||
},
|
||||
(true, false) => {
|
||||
let input_stream =
|
||||
json_array_to_stream(request.body).map_err(DynamicCallError::InvalidInput)?;
|
||||
let result = self
|
||||
.state
|
||||
.grpc_client
|
||||
.client_streaming(method, input_stream, request.headers)
|
||||
.await?;
|
||||
Ok(DynamicResponse::Unary(result))
|
||||
}
|
||||
(true, true) => {
|
||||
let input_stream =
|
||||
json_array_to_stream(request.body).map_err(DynamicCallError::InvalidInput)?;
|
||||
match self
|
||||
.state
|
||||
.grpc_client
|
||||
.bidirectional_streaming(method, input_stream, request.headers)
|
||||
.await?
|
||||
{
|
||||
Ok(stream) => Ok(DynamicResponse::Streaming(stream.boxed())),
|
||||
Err(status) => Ok(DynamicResponse::Streaming(
|
||||
stream::once(async { Err(status) }).boxed(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
90
granc-core/src/client/types.rs
Normal file
90
granc-core/src/client/types.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
use futures_util::stream::BoxStream;
|
||||
use prost_reflect::{EnumDescriptor, MessageDescriptor, ServiceDescriptor};
|
||||
use std::fmt::Debug;
|
||||
|
||||
/// A request object encapsulating all necessary information to perform a dynamic gRPC call.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DynamicRequest {
|
||||
/// The JSON body of the request.
|
||||
/// - For Unary/ServerStreaming: An Object `{}`.
|
||||
/// - For ClientStreaming/Bidirectional: An Array of Objects `[{}]`.
|
||||
pub body: serde_json::Value,
|
||||
/// Custom gRPC metadata (headers) to attach to the request.
|
||||
pub headers: Vec<(String, String)>,
|
||||
/// The fully qualified name of the service (e.g., `my.package.Service`).
|
||||
pub service: String,
|
||||
/// The name of the method to call (e.g., `SayHello`).
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
/// The result of a dynamic gRPC call.
|
||||
pub enum DynamicResponse {
|
||||
/// A single response message (for Unary and Client Streaming calls).
|
||||
Unary(Result<serde_json::Value, tonic::Status>),
|
||||
/// A stream of response messages (for Server Streaming and Bidirectional calls).
|
||||
Streaming(BoxStream<'static, Result<serde_json::Value, tonic::Status>>),
|
||||
}
|
||||
|
||||
/// A generic wrapper for different types of Protobuf descriptors.
|
||||
///
|
||||
/// This enum allows the client to return a single type when resolving symbols,
|
||||
/// regardless of whether the symbol points to a Service, a Message, or an Enum.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Descriptor {
|
||||
MessageDescriptor(MessageDescriptor),
|
||||
ServiceDescriptor(ServiceDescriptor),
|
||||
EnumDescriptor(EnumDescriptor),
|
||||
}
|
||||
|
||||
impl Descriptor {
|
||||
/// Returns the name (e.g.,`MyMessage`) of the inner descriptor
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Descriptor::MessageDescriptor(v) => v.name(),
|
||||
Descriptor::ServiceDescriptor(v) => v.name(),
|
||||
Descriptor::EnumDescriptor(v) => v.name(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the full_name (e.g.,`my.package.v1.MyMessage`) of the inner descriptor
|
||||
pub fn full_name(&self) -> &str {
|
||||
match self {
|
||||
Descriptor::MessageDescriptor(v) => v.full_name(),
|
||||
Descriptor::ServiceDescriptor(v) => v.full_name(),
|
||||
Descriptor::EnumDescriptor(v) => v.full_name(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the package name (e.g.,`my.package.v1`) of the inner descriptor
|
||||
pub fn package_name(&self) -> &str {
|
||||
match self {
|
||||
Descriptor::MessageDescriptor(v) => v.package_name(),
|
||||
Descriptor::ServiceDescriptor(v) => v.package_name(),
|
||||
Descriptor::EnumDescriptor(v) => v.package_name(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the inner [`MessageDescriptor`] if this variant is `MessageDescriptor`.
|
||||
pub fn message_descriptor(&self) -> Option<&MessageDescriptor> {
|
||||
match self {
|
||||
Descriptor::MessageDescriptor(d) => Some(d),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the inner [`ServiceDescriptor`] if this variant is `ServiceDescriptor`.
|
||||
pub fn service_descriptor(&self) -> Option<&ServiceDescriptor> {
|
||||
match self {
|
||||
Descriptor::ServiceDescriptor(d) => Some(d),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the inner [`EnumDescriptor`] if this variant is `EnumDescriptor`.
|
||||
pub fn enum_descriptor(&self) -> Option<&EnumDescriptor> {
|
||||
match self {
|
||||
Descriptor::EnumDescriptor(d) => Some(d),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
201
granc-core/src/grpc/client.rs
Normal file
201
granc-core/src/grpc/client.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
//! # Generic gRPC Client
|
||||
//!
|
||||
//! This module wraps a standard `tonic` client to provide a generic interface for
|
||||
//! gRPC communication. It is agnostic to the specific Protobuf messages being exchanged.
|
||||
//!
|
||||
//! ## How it works
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! * **Dynamic Pathing**: Constructs the HTTP/2 path (e.g., `/package.Service/Method`) at runtime.
|
||||
//! * **Metadata Handling**: Converts standard Rust string tuples into Tonic's `MetadataMap` for headers.
|
||||
//! * **Access Patterns**: Provides specific methods for Unary, Server Streaming, Client Streaming,
|
||||
//! and Bidirectional Streaming calls.
|
||||
use super::codec::JsonCodec;
|
||||
use crate::BoxError;
|
||||
use futures_util::Stream;
|
||||
use http_body::Body as HttpBody;
|
||||
use prost_reflect::MethodDescriptor;
|
||||
use std::str::FromStr;
|
||||
use tonic::{
|
||||
client::GrpcService,
|
||||
metadata::{
|
||||
MetadataKey, MetadataValue,
|
||||
errors::{InvalidMetadataKey, InvalidMetadataValue},
|
||||
},
|
||||
transport::Channel,
|
||||
};
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum GrpcRequestError {
|
||||
#[error("Internal error, the client was not ready: '{0}'")]
|
||||
ClientNotReady(#[source] BoxError),
|
||||
#[error("Invalid metadata (header) key '{key}': '{source}'")]
|
||||
InvalidMetadataKey {
|
||||
key: String,
|
||||
source: InvalidMetadataKey,
|
||||
},
|
||||
#[error("Invalid metadata (header) value for key '{key}': '{source}'")]
|
||||
InvalidMetadataValue {
|
||||
key: String,
|
||||
source: InvalidMetadataValue,
|
||||
},
|
||||
}
|
||||
|
||||
/// A generic client for the gRPC Server Reflection Protocol.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GrpcClient<S = Channel> {
|
||||
client: tonic::client::Grpc<S>,
|
||||
}
|
||||
|
||||
impl<S> GrpcClient<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(service: S) -> Self {
|
||||
let client = tonic::client::Grpc::new(service);
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// Performs a Unary gRPC call (Single Request -> Single Response).
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Ok(Value))` - Successful RPC execution.
|
||||
/// * `Ok(Err(Status))` - RPC executed, but server returned an error.
|
||||
/// * `Err(ClientError)` - Failed to send request or connect.
|
||||
pub async fn unary(
|
||||
&mut self,
|
||||
method: MethodDescriptor,
|
||||
payload: serde_json::Value,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<Result<serde_json::Value, tonic::Status>, GrpcRequestError> {
|
||||
self.client
|
||||
.ready()
|
||||
.await
|
||||
.map_err(|e| GrpcRequestError::ClientNotReady(e.into()))?;
|
||||
|
||||
let codec = JsonCodec::new(method.input(), method.output());
|
||||
let path = http_path(&method);
|
||||
let request = build_request(payload, headers)?;
|
||||
|
||||
match self.client.unary(request, path, codec).await {
|
||||
Ok(response) => Ok(Ok(response.into_inner())),
|
||||
Err(status) => Ok(Err(status)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a Server Streaming gRPC call (Single Request -> Stream of Responses).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Ok(Stream))` - Successful RPC execution.
|
||||
/// * `Ok(Err(Status))` - RPC executed, but server returned an error.
|
||||
/// * `Err(ClientError)` - Failed to send request or connect.
|
||||
pub async fn server_streaming(
|
||||
&mut self,
|
||||
method: MethodDescriptor,
|
||||
payload: serde_json::Value,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<Result<tonic::Streaming<serde_json::Value>, tonic::Status>, GrpcRequestError> {
|
||||
self.client
|
||||
.ready()
|
||||
.await
|
||||
.map_err(|e| GrpcRequestError::ClientNotReady(e.into()))?;
|
||||
|
||||
let codec = JsonCodec::new(method.input(), method.output());
|
||||
let path = http_path(&method);
|
||||
let request = build_request(payload, headers)?;
|
||||
|
||||
match self.client.server_streaming(request, path, codec).await {
|
||||
Ok(response) => Ok(Ok(response.into_inner())),
|
||||
Err(status) => Ok(Err(status)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a Client Streaming gRPC call (Stream of Requests -> Single Response).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Ok(Value))` - Successful RPC execution.
|
||||
/// * `Ok(Err(Status))` - RPC executed, but server returned an error.
|
||||
/// * `Err(ClientError)` - Failed to send request or connect.
|
||||
pub async fn client_streaming(
|
||||
&mut self,
|
||||
method: MethodDescriptor,
|
||||
payload_stream: impl Stream<Item = serde_json::Value> + Send + 'static,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<Result<serde_json::Value, tonic::Status>, GrpcRequestError> {
|
||||
self.client
|
||||
.ready()
|
||||
.await
|
||||
.map_err(|e| GrpcRequestError::ClientNotReady(e.into()))?;
|
||||
|
||||
let codec = JsonCodec::new(method.input(), method.output());
|
||||
let path = http_path(&method);
|
||||
let request = build_request(payload_stream, headers)?;
|
||||
|
||||
match self.client.client_streaming(request, path, codec).await {
|
||||
Ok(response) => Ok(Ok(response.into_inner())),
|
||||
Err(status) => Ok(Err(status)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a Bidirectional Streaming gRPC call (Stream of Requests -> Stream of Responses).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Ok(Stream))` - Successful RPC execution.
|
||||
/// * `Ok(Err(Status))` - RPC executed, but server returned an error.
|
||||
/// * `Err(ClientError)` - Failed to send request or connect.
|
||||
pub async fn bidirectional_streaming(
|
||||
&mut self,
|
||||
method: MethodDescriptor,
|
||||
payload_stream: impl Stream<Item = serde_json::Value> + Send + 'static,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<Result<tonic::Streaming<serde_json::Value>, tonic::Status>, GrpcRequestError> {
|
||||
self.client
|
||||
.ready()
|
||||
.await
|
||||
.map_err(|e| GrpcRequestError::ClientNotReady(e.into()))?;
|
||||
|
||||
let codec = JsonCodec::new(method.input(), method.output());
|
||||
let path = http_path(&method);
|
||||
let request = build_request(payload_stream, headers)?;
|
||||
|
||||
match self.client.streaming(request, path, codec).await {
|
||||
Ok(response) => Ok(Ok(response.into_inner())),
|
||||
Err(status) => Ok(Err(status)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn http_path(method: &MethodDescriptor) -> http::uri::PathAndQuery {
|
||||
let path = format!("/{}/{}", method.parent_service().full_name(), method.name());
|
||||
http::uri::PathAndQuery::from_str(&path).expect("valid gRPC path")
|
||||
}
|
||||
|
||||
fn build_request<T>(
|
||||
payload: T,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<tonic::Request<T>, GrpcRequestError> {
|
||||
let mut request = tonic::Request::new(payload);
|
||||
for (k, v) in headers {
|
||||
let key =
|
||||
MetadataKey::from_str(&k).map_err(|source| GrpcRequestError::InvalidMetadataKey {
|
||||
key: k.clone(),
|
||||
source,
|
||||
})?;
|
||||
let val = MetadataValue::from_str(&v)
|
||||
.map_err(|source| GrpcRequestError::InvalidMetadataValue { key: k, source })?;
|
||||
request.metadata_mut().insert(key, val);
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
|
|
@ -1,12 +1,19 @@
|
|||
//! # JSON <-> Protobuf Codec
|
||||
//!
|
||||
//! This module implements a custom `tonic::codec::Codec` that allows `tonic` to work
|
||||
//! directly with `serde_json::Value`.
|
||||
//! This module implements `tonic::codec::Codec` to enable `tonic` to transport `serde_json::Value`
|
||||
//! directly, bypassing the need for generated Rust structs.
|
||||
//!
|
||||
//! It acts as a bridge:
|
||||
//! - **Encoding (Request):** Takes a JSON value -> Validates against Schema -> Serializes to Protobuf bytes.
|
||||
//! - **Decoding (Response):** Takes Protobuf bytes -> Deserializes using Schema -> Converts to JSON value.
|
||||
|
||||
//! ## How it works
|
||||
//!
|
||||
//! 1. **Encoder (JSON -> Proto)**:
|
||||
//! - Takes a `serde_json::Value`.
|
||||
//! - Uses `prost_reflect::DynamicMessage` to validate the JSON against the input `MessageDescriptor`.
|
||||
//! - Serializes the valid message into the generic gRPC byte buffer.
|
||||
//!
|
||||
//! 2. **Decoder (Proto -> JSON)**:
|
||||
//! - Reads raw bytes from the wire.
|
||||
//! - Decodes them into a `DynamicMessage` using the output `MessageDescriptor`.
|
||||
//! - Converts the message back into a `serde_json::Value` for the CLI to print.
|
||||
use prost::Message;
|
||||
use prost_reflect::{DynamicMessage, MessageDescriptor};
|
||||
use tonic::{
|
||||
46
granc-core/src/lib.rs
Normal file
46
granc-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//! # 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`.
|
||||
//!
|
||||
//! ## 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>;
|
||||
7
granc-core/src/reflection.rs
Normal file
7
granc-core/src/reflection.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//! # 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;
|
||||
284
granc-core/src/reflection/client.rs
Normal file
284
granc-core/src/reflection/client.rs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
//! # Reflection Client
|
||||
//!
|
||||
//! This module provides a client implementation for the gRPC Server Reflection Protocol (`grpc.reflection.v1`).
|
||||
//!
|
||||
//! The [`ReflectionClient`] allows `granc` to inspect the schema of a running gRPC server at runtime.
|
||||
//! It is capable of:
|
||||
//!
|
||||
//! 1. **Listing Services**: Querying the server for all exposed service names.
|
||||
//! 2. **Symbol Resolution**: Fetching the `FileDescriptorProto` for a specific symbol (Service or Message).
|
||||
//! 3. **Dependency Management**: Automatically identifying missing imports in a file descriptor and recursively
|
||||
//! fetching them from the server to build a complete, self-contained `FileDescriptorSet`.
|
||||
//!
|
||||
//! This client is designed to be resilient and handles the recursive graph traversal required to reconstruct
|
||||
//! the full proto set from individual file descriptors.
|
||||
//!
|
||||
//! ## References
|
||||
//!
|
||||
//! * [gRPC Server Reflection Protocol](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md)
|
||||
use crate::BoxError;
|
||||
use futures_util::stream::once;
|
||||
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};
|
||||
use tonic_reflection::pb::v1::{
|
||||
ServerReflectionRequest, ServerReflectionResponse,
|
||||
server_reflection_client::ServerReflectionClient, server_reflection_request::MessageRequest,
|
||||
server_reflection_response::MessageResponse,
|
||||
};
|
||||
|
||||
/// Errors that can occur during reflection resolution.
|
||||
#[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 client for interacting with the gRPC Server Reflection Service.
|
||||
#[derive(Debug, Clone)]
|
||||
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,
|
||||
{
|
||||
/// Creates a new `ReflectionClient` using the provided gRPC service (e.g., a `Channel`).
|
||||
pub fn new(channel: S) -> Self {
|
||||
let client = ServerReflectionClient::new(channel);
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// Fetches the complete `FileDescriptorSet` containing the definition for the given symbol.
|
||||
///
|
||||
/// This method performs a recursive lookup:
|
||||
/// 1. It asks the server for the file defining `service_name`.
|
||||
/// 2. It parses the response and identifies any imported files (dependencies).
|
||||
/// 3. It recursively requests those dependencies if they haven't been fetched yet.
|
||||
/// 4. It aggregates all fetched files into a single `FileDescriptorSet`.
|
||||
///
|
||||
/// This ensures that the returned set is self-contained and can be used to build a
|
||||
/// `prost_reflect::DescriptorPool`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `symbol` - The fully qualified symbol name to resolve (e.g., `my.package.MyService`, `my.package.Message`).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(FileDescriptorSet)` - A set containing the file defining the symbol and all its transitive dependencies.
|
||||
/// * `Err(ReflectionResolveError)` - If the symbol is not found, the server doesn't support reflection, or a protocol error occurs.
|
||||
pub async fn file_descriptor_set_by_symbol(
|
||||
&mut self,
|
||||
symbol: &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(symbol.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)
|
||||
}
|
||||
|
||||
/// Lists all services exposed by the server.
|
||||
///
|
||||
/// Sends a `ListServices` request to the reflection endpoint.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<String>)` - A string list where each string is a fully qualified service name (e.g., `grpc.reflection.v1.ServerReflection`, `helloworld.Greeter`).
|
||||
/// * `Err(ReflectionResolveError)` - If the server doesn't support reflection or a protocol error occurs.
|
||||
pub async fn list_services(&mut self) -> Result<Vec<String>, ReflectionResolveError> {
|
||||
let req = ServerReflectionRequest {
|
||||
host: EMPTY_HOST.to_string(),
|
||||
message_request: Some(MessageRequest::ListServices(String::new())),
|
||||
};
|
||||
|
||||
let mut response_stream = self
|
||||
.client
|
||||
.server_reflection_info(once(async { req }))
|
||||
.await
|
||||
.map_err(ReflectionResolveError::ServerStreamInitFailed)?
|
||||
.into_inner();
|
||||
|
||||
let response = response_stream
|
||||
.message()
|
||||
.await
|
||||
.map_err(ReflectionResolveError::ServerStreamFailure)?
|
||||
.ok_or(ReflectionResolveError::StreamClosed)?;
|
||||
|
||||
match response.message_response {
|
||||
Some(MessageResponse::ListServicesResponse(resp)) => {
|
||||
let services = resp.service.into_iter().map(|s| s.name).collect();
|
||||
Ok(services)
|
||||
}
|
||||
Some(MessageResponse::ErrorResponse(e)) => Err(ReflectionResolveError::ServerError {
|
||||
code: e.error_code,
|
||||
message: e.error_message,
|
||||
}),
|
||||
Some(other) => Err(ReflectionResolveError::UnexpectedResponseType(format!(
|
||||
"{other:?}",
|
||||
))),
|
||||
None => Err(ReflectionResolveError::UnexpectedResponseType(
|
||||
"Empty Message".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,12 +1,13 @@
|
|||
use echo_service::EchoService;
|
||||
use echo_service::pb::{EchoRequest, EchoResponse};
|
||||
|
||||
use futures_util::Stream;
|
||||
use futures_util::StreamExt;
|
||||
use granc_test_support::echo_service::EchoService;
|
||||
use granc_test_support::echo_service::pb::{EchoRequest, EchoResponse};
|
||||
use std::pin::Pin;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::{StreamExt, wrappers::ReceiverStream};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::{Request, Response, Status, Streaming};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EchoServiceImpl;
|
||||
|
||||
#[tonic::async_trait]
|
||||
49
granc-core/tests/granc_client_offline_test.rs
Normal file
49
granc-core/tests/granc_client_offline_test.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
use granc_core::client::{Descriptor, GrancClient};
|
||||
use granc_test_support::echo_service::FILE_DESCRIPTOR_SET;
|
||||
|
||||
#[test]
|
||||
fn test_offline_list_services() {
|
||||
let client = GrancClient::offline(FILE_DESCRIPTOR_SET.to_vec())
|
||||
.expect("Failed to load file descriptor set");
|
||||
|
||||
let mut services = client.list_services();
|
||||
services.sort();
|
||||
|
||||
assert_eq!(services.as_slice(), ["echo.EchoService"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offline_describe_descriptors() {
|
||||
let client = GrancClient::offline(FILE_DESCRIPTOR_SET.to_vec())
|
||||
.expect("Failed to load file descriptor set");
|
||||
|
||||
// 1. Success: Service
|
||||
let desc = client
|
||||
.get_descriptor_by_symbol("echo.EchoService")
|
||||
.expect("Service not found");
|
||||
|
||||
assert!(matches!(
|
||||
desc,
|
||||
Descriptor::ServiceDescriptor(s) if s.name() == "EchoService"
|
||||
));
|
||||
|
||||
// 2. Success: Message
|
||||
let desc = client
|
||||
.get_descriptor_by_symbol("echo.EchoRequest")
|
||||
.expect("Message not found");
|
||||
|
||||
assert!(matches!(
|
||||
desc,
|
||||
Descriptor::MessageDescriptor(m) if m.name() == "EchoRequest"
|
||||
));
|
||||
|
||||
// 3. Error: Symbol Not Found
|
||||
let desc = client.get_descriptor_by_symbol("echo.Ghost");
|
||||
assert!(desc.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offline_creation_error() {
|
||||
let result = GrancClient::offline(vec![0, 1, 2, 3]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
180
granc-core/tests/granc_client_online_test.rs
Normal file
180
granc-core/tests/granc_client_online_test.rs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
use echo_service_impl::EchoServiceImpl;
|
||||
use granc_core::client::{DynamicRequest, DynamicResponse, GrancClient, Online, online};
|
||||
use granc_core::reflection::client::ReflectionResolveError;
|
||||
use granc_test_support::echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET};
|
||||
use tokio_stream::StreamExt;
|
||||
use tonic::Code;
|
||||
use tonic::service::Routes;
|
||||
|
||||
mod echo_service_impl;
|
||||
|
||||
async fn setup_client() -> GrancClient<Online<Routes>> {
|
||||
// Enable Reflection
|
||||
let reflection_service = tonic_reflection::server::Builder::configure()
|
||||
.register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
|
||||
.build_v1()
|
||||
.unwrap();
|
||||
|
||||
let echo_service = EchoServiceServer::new(EchoServiceImpl);
|
||||
|
||||
let service = Routes::new(reflection_service).add_service(echo_service);
|
||||
|
||||
GrancClient::from(service)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_list_services() {
|
||||
let mut client = setup_client().await;
|
||||
let mut services = client.list_services().await.unwrap();
|
||||
services.sort();
|
||||
|
||||
assert_eq!(
|
||||
services.as_slice(),
|
||||
["echo.EchoService", "grpc.reflection.v1.ServerReflection"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_unary_success() {
|
||||
let mut client = setup_client().await;
|
||||
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "UnaryEcho".to_string(),
|
||||
body: serde_json::json!({ "message": "reflection" }),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let res = client.dynamic(req).await.unwrap();
|
||||
assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "reflection"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_server_streaming_success() {
|
||||
let mut client = setup_client().await;
|
||||
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "ServerStreamingEcho".to_string(),
|
||||
body: serde_json::json!({ "message": "stream" }),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let res = client.dynamic(req).await.unwrap();
|
||||
|
||||
match res {
|
||||
DynamicResponse::Streaming(stream) => {
|
||||
let stream: Vec<_> = stream.collect().await;
|
||||
|
||||
assert_eq!(stream.len(), 3);
|
||||
assert_eq!(stream[0].as_ref().unwrap()["message"], "stream - seq 0");
|
||||
assert_eq!(stream[1].as_ref().unwrap()["message"], "stream - seq 1");
|
||||
assert_eq!(stream[2].as_ref().unwrap()["message"], "stream - seq 2");
|
||||
}
|
||||
_ => panic!("Expected Streaming response"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_client_streaming_success() {
|
||||
let mut client = setup_client().await;
|
||||
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "ClientStreamingEcho".to_string(),
|
||||
body: serde_json::json!([{ "message": "A" }, { "message": "B" }]),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let res = client.dynamic(req).await.unwrap();
|
||||
assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "AB"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_service_not_found() {
|
||||
let mut client = setup_client().await;
|
||||
|
||||
// Requesting a service that doesn't exist on the server.
|
||||
// This fails during the Reflection Lookup phase.
|
||||
let req = DynamicRequest {
|
||||
service: "echo.GhostService".to_string(),
|
||||
method: "UnaryEcho".to_string(),
|
||||
body: serde_json::json!({}),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let result = client.dynamic(req).await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(online::DynamicCallError::ReflectionResolve(
|
||||
ReflectionResolveError::ServerStreamFailure(status)
|
||||
)) if status.code() == Code::NotFound
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_method_not_found() {
|
||||
let mut client = setup_client().await;
|
||||
|
||||
// The service exists, so reflection succeeds in fetching the schema.
|
||||
// However, the schema does not contain "GhostMethod", so it fails locally before call.
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "GhostMethod".to_string(),
|
||||
body: serde_json::json!({}),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let result = client.dynamic(req).await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(online::DynamicCallError::DynamicCallError(
|
||||
granc_core::client::online_without_reflection::DynamicCallError::MethodNotFound(name)
|
||||
)) if name == "GhostMethod"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_invalid_input_structure() {
|
||||
let mut client = setup_client().await;
|
||||
|
||||
// Client streaming requires Array.
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "ClientStreamingEcho".to_string(),
|
||||
body: serde_json::json!({ "msg": "not array" }),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let result = client.dynamic(req).await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(online::DynamicCallError::DynamicCallError(
|
||||
granc_core::client::online_without_reflection::DynamicCallError::InvalidInput(_)
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reflection_schema_mismatch() {
|
||||
let mut client = setup_client().await;
|
||||
|
||||
// Field "wrong_field" does not exist in the protobuf definition.
|
||||
// Should fail with InvalidArgument during encoding.
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "UnaryEcho".to_string(),
|
||||
body: serde_json::json!({ "wrong_field": "val" }),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let result = client.dynamic(req).await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Ok(DynamicResponse::Unary(Err(status))) if status.code() == Code::Internal
|
||||
));
|
||||
}
|
||||
199
granc-core/tests/granc_client_online_without_reflection_test.rs
Normal file
199
granc-core/tests/granc_client_online_without_reflection_test.rs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
use echo_service_impl::EchoServiceImpl;
|
||||
use futures_util::StreamExt;
|
||||
use granc_core::client::{
|
||||
DynamicRequest, DynamicResponse, GrancClient, OnlineWithoutReflection,
|
||||
online_without_reflection,
|
||||
};
|
||||
use granc_test_support::echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET};
|
||||
use tonic::Code;
|
||||
|
||||
mod echo_service_impl;
|
||||
|
||||
fn setup_client() -> GrancClient<OnlineWithoutReflection<EchoServiceServer<EchoServiceImpl>>> {
|
||||
let service = EchoServiceServer::new(EchoServiceImpl);
|
||||
let client_reflection = GrancClient::from(service);
|
||||
|
||||
client_reflection
|
||||
.with_file_descriptor(FILE_DESCRIPTOR_SET.to_vec())
|
||||
.expect("Failed to load file descriptor set")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dynamic_unary_success() {
|
||||
let mut client = setup_client();
|
||||
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "UnaryEcho".to_string(),
|
||||
body: serde_json::json!({ "message": "hello" }),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let res = client.dynamic(req).await.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
res,
|
||||
DynamicResponse::Unary(Ok(val)) if val["message"] == "hello"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dynamic_server_streaming_success() {
|
||||
let mut client = setup_client();
|
||||
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "ServerStreamingEcho".to_string(),
|
||||
body: serde_json::json!({ "message": "stream" }),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let res = client.dynamic(req).await.unwrap();
|
||||
|
||||
match res {
|
||||
DynamicResponse::Streaming(stream) => {
|
||||
let stream: Vec<_> = stream.collect().await;
|
||||
|
||||
assert_eq!(stream.len(), 3);
|
||||
assert_eq!(stream[0].as_ref().unwrap()["message"], "stream - seq 0");
|
||||
assert_eq!(stream[1].as_ref().unwrap()["message"], "stream - seq 1");
|
||||
assert_eq!(stream[2].as_ref().unwrap()["message"], "stream - seq 2");
|
||||
}
|
||||
_ => panic!("Expected Streaming response"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dynamic_client_streaming_success() {
|
||||
let mut client = setup_client();
|
||||
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "ClientStreamingEcho".to_string(),
|
||||
// Client streaming requires a JSON Array
|
||||
body: serde_json::json!([
|
||||
{ "message": "A" },
|
||||
{ "message": "B" },
|
||||
{ "message": "C" }
|
||||
]),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let res = client.dynamic(req).await.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
res,
|
||||
DynamicResponse::Unary(Ok(val)) if val["message"] == "ABC"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dynamic_bidirectional_streaming_success() {
|
||||
let mut client = setup_client();
|
||||
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "BidirectionalEcho".to_string(),
|
||||
body: serde_json::json!([
|
||||
{ "message": "Ping" },
|
||||
{ "message": "Pong" }
|
||||
]),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let res = client.dynamic(req).await.unwrap();
|
||||
|
||||
match res {
|
||||
DynamicResponse::Streaming(stream) => {
|
||||
let stream: Vec<_> = stream.collect().await;
|
||||
|
||||
assert_eq!(stream.len(), 2);
|
||||
assert_eq!(stream[0].as_ref().unwrap()["message"], "echo: Ping");
|
||||
assert_eq!(stream[1].as_ref().unwrap()["message"], "echo: Pong");
|
||||
}
|
||||
_ => panic!("Expected Streaming response"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_service_not_found() {
|
||||
let mut client = setup_client();
|
||||
|
||||
let req = DynamicRequest {
|
||||
service: "echo.GhostService".to_string(),
|
||||
method: "UnaryEcho".to_string(),
|
||||
body: serde_json::json!({}),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let result = client.dynamic(req).await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(online_without_reflection::DynamicCallError::ServiceNotFound(name)) if name == "echo.GhostService"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_method_not_found() {
|
||||
let mut client = setup_client();
|
||||
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "GhostMethod".to_string(),
|
||||
body: serde_json::json!({}),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let result = client.dynamic(req).await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(online_without_reflection::DynamicCallError::MethodNotFound(name)) if name == "GhostMethod"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_invalid_input_structure() {
|
||||
let mut client = setup_client();
|
||||
|
||||
// Client streaming requires an Array, passing an Object should fail
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "ClientStreamingEcho".to_string(),
|
||||
body: serde_json::json!({ "message": "I should be an array" }),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let result = client.dynamic(req).await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(online_without_reflection::DynamicCallError::InvalidInput(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_schema_mismatch() {
|
||||
let mut client = setup_client();
|
||||
|
||||
// Passing a field ("unknown_field") that doesn't exist in the EchoRequest proto definition.
|
||||
// The JsonCodec (in granc-core/src/grpc/codec.rs) maps this to Status::InvalidArgument.
|
||||
let req = DynamicRequest {
|
||||
service: "echo.EchoService".to_string(),
|
||||
method: "UnaryEcho".to_string(),
|
||||
body: serde_json::json!({ "unknown_field": 123 }),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
let result = client.dynamic(req).await;
|
||||
|
||||
// This error happens during encoding inside the Tonic stack, so it returns
|
||||
// a successful Result<DynamicResponse> containing an Err(Status).
|
||||
assert!(matches!(
|
||||
result,
|
||||
Ok(DynamicResponse::Unary(Err(status)))
|
||||
if status.code() == Code::Internal
|
||||
&& status.message().contains("JSON structure does not match Protobuf schema")
|
||||
));
|
||||
}
|
||||
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 echo_service_impl::EchoServiceImpl;
|
||||
use granc_core::reflection::client::{ReflectionClient, ReflectionResolveError};
|
||||
use granc_test_support::echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET};
|
||||
use prost_reflect::DescriptorPool;
|
||||
use tonic::Code;
|
||||
use tonic_reflection::server::v1::ServerReflectionServer;
|
||||
|
||||
mod 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(EchoServiceImpl);
|
||||
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"),
|
||||
}
|
||||
}
|
||||
16
granc-test-support/Cargo.toml
Normal file
16
granc-test-support/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "granc-test-support"
|
||||
edition = { workspace = true }
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
bytes = "1"
|
||||
prost = { workspace = true }
|
||||
tonic = { workspace = true }
|
||||
prost-types = { workspace = true }
|
||||
tonic-prost = { workspace = true }
|
||||
prost-build = { workspace = true }
|
||||
tempfile = "3"
|
||||
|
||||
[build-dependencies]
|
||||
tonic-prost-build = { workspace = true }
|
||||
36
granc-test-support/src/compiler.rs
Normal file
36
granc-test-support/src/compiler.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
//! This module provides tools to compile protobuffer files at runtime.
|
||||
use prost::Message;
|
||||
use prost_types::FileDescriptorSet;
|
||||
use std::fs;
|
||||
|
||||
/// Compiles inline proto strings into a FileDescriptorSet at runtime.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `files` - A list of tuples (filename, content). E.g. `[("test.proto", "syntax=...")]`
|
||||
pub fn compile_protos(files: &[(&str, &str)]) -> FileDescriptorSet {
|
||||
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
|
||||
let descriptor_path = temp_dir.path().join("descriptor.bin");
|
||||
let proto_dir = temp_dir.path().join("protos");
|
||||
|
||||
fs::create_dir(&proto_dir).expect("Failed to create protos dir");
|
||||
|
||||
let paths: Vec<_> = files
|
||||
.iter()
|
||||
.map(|(name, content)| {
|
||||
let path = proto_dir.join(name);
|
||||
fs::write(&path, content).expect("Failed to write proto file");
|
||||
path
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut config = prost_build::Config::new();
|
||||
config.file_descriptor_set_path(&descriptor_path);
|
||||
config.out_dir(temp_dir.path());
|
||||
config
|
||||
.compile_protos(&paths, &[proto_dir])
|
||||
.expect("Failed to compile protos");
|
||||
|
||||
let bytes = fs::read(descriptor_path).expect("Failed to read descriptor set");
|
||||
|
||||
FileDescriptorSet::decode(bytes.as_slice()).expect("Failed to decode File descriptor set")
|
||||
}
|
||||
2
granc-test-support/src/lib.rs
Normal file
2
granc-test-support/src/lib.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod compiler;
|
||||
pub mod echo_service;
|
||||
|
|
@ -1,22 +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.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,32 +1,32 @@
|
|||
[package]
|
||||
authors = ["Victor Martínez Montané <jaster.victor@gmail.com>"]
|
||||
authors = { workspace = true }
|
||||
categories = ["network-programming", "command-line-utilities"]
|
||||
description = "A dynamic gRPC CLI tool written in Rust {Granc -> gRPC + Cranc (Crab in Catalan)}"
|
||||
edition = "2024"
|
||||
homepage = "https://github.com/JasterV/granc"
|
||||
description = "A dynamic gRPC CLI tool written in Rust (gRPC + Cranc, Crab in Catalan)"
|
||||
edition = { workspace = true }
|
||||
homepage = { workspace = true }
|
||||
keywords = ["cli", "command-line", "grpc", "grpcurl", "curl"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
license = { workspace = true }
|
||||
name = "granc"
|
||||
publish = true
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/JasterV/granc"
|
||||
rust-version = "1.89"
|
||||
version = "0.1.0"
|
||||
readme = "../README.md"
|
||||
repository = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
version = "0.7.6"
|
||||
|
||||
[package.metadata.binstall]
|
||||
pkg-url = "{ repo }/releases/download/{ name }-v{ version }/{ name }-{ target }.{ archive-format }"
|
||||
pkg-fmt = "tgz"
|
||||
|
||||
[package.metadata.binstall.overrides.x86_64-pc-windows-msvc]
|
||||
pkg-fmt = "zip"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
clap = { version = "4.5.54", features = ["derive"] }
|
||||
futures-util = "0.3.31"
|
||||
http = "1.4.0"
|
||||
http-body = "1.0.1"
|
||||
prost = "0.14.3"
|
||||
prost-reflect = { version = "0.16.3", features = ["serde"] }
|
||||
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"
|
||||
clap = { version = "4.6.1", features = ["derive"] }
|
||||
colored = "3.1.1"
|
||||
granc_core = { version = "0.6.4", path = "../granc-core" }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
futures-util = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
echo-service = { path = "../echo-service" }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] }
|
||||
granc-test-support = { path = "../granc-test-support" }
|
||||
|
|
|
|||
146
granc/README.md
146
granc/README.md
|
|
@ -1,146 +0,0 @@
|
|||
# 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.
|
||||
* **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.
|
||||
* **Metadata Support**: Easily attach custom headers (authorization, tracing) to your requests.
|
||||
* **Tonic 0.14**: Built on the latest stable Rust gRPC stack.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### From Source
|
||||
|
||||
Ensure you have Rust and Cargo installed.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/JasterV/granc
|
||||
cd granc
|
||||
cargo install --path .
|
||||
```
|
||||
|
||||
## 🛠️ Prerequisites: Generating Descriptors
|
||||
|
||||
To use Granc, you currently need a binary **FileDescriptorSet** (`.bin` or `.pb`). This file contains the schema definitions for your services.
|
||||
|
||||
You can generate this using the standard `protoc` compiler:
|
||||
|
||||
```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> <METHOD>
|
||||
|
||||
```
|
||||
|
||||
### Arguments
|
||||
|
||||
| Argument | Description | Required |
|
||||
| --- | --- | --- |
|
||||
| `<URL>` | Server address (e.g., `http://[::1]:50051`). | **Yes** |
|
||||
| `<METHOD>` | Fully qualified method name (e.g., `my.package.Service/Method`). | **Yes** |
|
||||
|
||||
### Options
|
||||
|
||||
| Flag | Short | Description | Required |
|
||||
| --- | --- | --- | --- |
|
||||
| `--proto-set` | | Path to the binary FileDescriptorSet (`.bin`). | **Yes** |
|
||||
| `--body` | | The request body in JSON format. | **Yes** |
|
||||
| `--header` | `-H` | Custom header `key:value`. Can be used multiple times. | No |
|
||||
|
||||
### JSON Body Format
|
||||
|
||||
* **Unary / Server Streaming**: Provide a single JSON object `{ ... }`.
|
||||
* **Client / Bidirectional Streaming**: Provide a JSON array of objects `[ { ... }, { ... } ]`.
|
||||
|
||||
### Examples
|
||||
|
||||
**1. Unary Call**
|
||||
|
||||
```bash
|
||||
granc \
|
||||
--proto-set ./descriptor.bin \
|
||||
--body '{"name": "Ferris"}' \
|
||||
http://localhost:50051 \
|
||||
helloworld.Greeter/SayHello
|
||||
```
|
||||
|
||||
**2. Bidirectional Streaming (Chat)**
|
||||
|
||||
```bash
|
||||
granc \
|
||||
--proto-set ./descriptor.bin \
|
||||
--body '[{"text": "Hello"}, {"text": "How are you?"}]' \
|
||||
-H "authorization: Bearer token123" \
|
||||
http://localhost:50051 \
|
||||
chat.ChatService/StreamMessages
|
||||
```
|
||||
|
||||
## 🔮 Roadmap
|
||||
|
||||
* **Automatic Server Reflection**: We are working on removing the requirement for the `--proto-set` file. Future versions will support fetching the schema directly from servers that have the [gRPC Server Reflection Protocol](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md) enabled.
|
||||
* **Interactive Mode**: A REPL for streaming requests interactively.
|
||||
* **Pretty Printing**: Enhanced colored output for JSON responses.
|
||||
|
||||
## ⚠️ 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 # Formats, lints, and builds
|
||||
```
|
||||
|
||||
## 📄 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.
|
||||
407
granc/src/cli.rs
Normal file
407
granc/src/cli.rs
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
//! # CLI
|
||||
//!
|
||||
//! This module defines the command-line interface of `granc` using `clap`.
|
||||
//! It enforces strict invariants for arguments using subcommands and argument groups.
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "granc", version, about = "Dynamic gRPC CLI")]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Commands {
|
||||
/// Perform a gRPC call to a server.
|
||||
///
|
||||
/// Requires a server URI. Can optionally use a local file descriptor set.
|
||||
Call {
|
||||
/// Endpoint (package.Service/Method)
|
||||
#[arg(value_parser = parse_endpoint)]
|
||||
endpoint: (String, String),
|
||||
|
||||
/// The server URI to connect to (e.g. http://localhost:50051)
|
||||
#[arg(long, short = 'u')]
|
||||
uri: String,
|
||||
|
||||
/// "JSON body (Object for Unary, Array for Streaming)"
|
||||
#[arg(long, short = 'b', value_parser = parse_body)]
|
||||
body: serde_json::Value,
|
||||
|
||||
#[arg(short = 'H', long = "header", value_parser = parse_header)]
|
||||
headers: Vec<(String, String)>,
|
||||
|
||||
/// Optional path to a file descriptor set (.bin) to use instead of reflection
|
||||
#[arg(long, short = 'f')]
|
||||
file_descriptor_set: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// List available services.
|
||||
///
|
||||
/// Requires EITHER a server URI (Reflection) OR a file descriptor set (Offline).
|
||||
List {
|
||||
#[command(flatten)]
|
||||
source: SourceSelection,
|
||||
},
|
||||
|
||||
/// Describe a service, message or enum.
|
||||
///
|
||||
/// Requires EITHER a server URI (Reflection) OR a file descriptor set (Offline).
|
||||
Describe {
|
||||
#[command(flatten)]
|
||||
source: SourceSelection,
|
||||
|
||||
/// Fully qualified name (e.g. my.package.Service)
|
||||
symbol: String,
|
||||
},
|
||||
|
||||
/// Generate Markdown documentation for a service.
|
||||
Doc {
|
||||
#[command(flatten)]
|
||||
source: SourceSelection,
|
||||
|
||||
/// Fully qualified service name (e.g. my.package.MyService)
|
||||
symbol: String,
|
||||
|
||||
/// Output directory for the generated markdown files
|
||||
#[arg(long, short = 'o')]
|
||||
output: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
#[group(required = true, multiple = false)] // Enforces: Either URI OR FileDescriptorSet, never both.
|
||||
pub struct SourceSelection {
|
||||
/// The server URI to use for reflection-based introspection
|
||||
#[arg(long, short = 'u')]
|
||||
uri: Option<String>,
|
||||
|
||||
/// Path to the descriptor set (.bin) to use for offline introspection
|
||||
#[arg(long, short = 'f')]
|
||||
file_descriptor_set: Option<PathBuf>,
|
||||
}
|
||||
|
||||
// The source where to resolve the proto schemas from.
|
||||
//
|
||||
// It can either be a URI (If the server supports server streaming)
|
||||
// or a file (a `.bin` or `.pb` file generated with protoc)
|
||||
pub enum Source {
|
||||
Uri(String),
|
||||
File(PathBuf),
|
||||
}
|
||||
|
||||
impl SourceSelection {
|
||||
pub fn value(self) -> Source {
|
||||
if let Some(uri) = self.uri {
|
||||
Source::Uri(uri)
|
||||
} else if let Some(path) = self.file_descriptor_set {
|
||||
Source::File(path)
|
||||
} else {
|
||||
// This is unreachable because `clap` verifies the group requirements before we ever get here.
|
||||
unreachable!(
|
||||
"Clap ensures exactly one argument (uri or file) is present via #[group(required = true)]"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_endpoint(value: &str) -> Result<(String, String), String> {
|
||||
let (service, method) = value.split_once('/').ok_or_else(|| {
|
||||
format!("Invalid endpoint format: '{value}'. Expected 'package.Service/Method'",)
|
||||
})?;
|
||||
|
||||
if service.trim().is_empty() || method.trim().is_empty() {
|
||||
return Err("Service and Method names cannot be empty".to_string());
|
||||
}
|
||||
|
||||
Ok((service.to_string(), method.to_string()))
|
||||
}
|
||||
|
||||
fn parse_header(s: &str) -> Result<(String, String), String> {
|
||||
s.split_once(':')
|
||||
.map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
|
||||
.ok_or_else(|| "Format must be 'key:value'".to_string())
|
||||
}
|
||||
|
||||
fn parse_body(value: &str) -> Result<serde_json::Value, String> {
|
||||
serde_json::from_str(value).map_err(|e| format!("Invalid JSON: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn test_call_command_reflection() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"call",
|
||||
"helloworld.Greeter/SayHello",
|
||||
"--uri",
|
||||
"http://localhost:50051",
|
||||
"--body",
|
||||
r#"{"name": "Ferris"}"#,
|
||||
];
|
||||
|
||||
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
|
||||
|
||||
match cli.command {
|
||||
Commands::Call {
|
||||
endpoint,
|
||||
uri,
|
||||
body,
|
||||
file_descriptor_set,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(
|
||||
endpoint,
|
||||
("helloworld.Greeter".to_string(), "SayHello".to_string())
|
||||
);
|
||||
assert_eq!(uri, "http://localhost:50051");
|
||||
assert_eq!(body, serde_json::json!({"name": "Ferris"}));
|
||||
assert!(file_descriptor_set.is_none());
|
||||
}
|
||||
_ => panic!("Expected Call command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_call_command_with_file_descriptor() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"call",
|
||||
"helloworld.Greeter/SayHello",
|
||||
"--uri",
|
||||
"http://localhost:50051",
|
||||
"--body",
|
||||
r#"{"name": "Ferris"}"#,
|
||||
"--file-descriptor-set",
|
||||
"./descriptors.bin",
|
||||
];
|
||||
|
||||
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
|
||||
|
||||
match cli.command {
|
||||
Commands::Call {
|
||||
file_descriptor_set,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(
|
||||
file_descriptor_set.unwrap().to_str().unwrap(),
|
||||
"./descriptors.bin"
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected Call command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_call_command_short_flags() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"call",
|
||||
"svc/mthd",
|
||||
"-u",
|
||||
"http://localhost:50051",
|
||||
"-b",
|
||||
"{}",
|
||||
"-f",
|
||||
"desc.bin",
|
||||
"-H",
|
||||
"auth:bearer",
|
||||
];
|
||||
|
||||
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
|
||||
|
||||
match cli.command {
|
||||
Commands::Call {
|
||||
uri,
|
||||
file_descriptor_set,
|
||||
headers,
|
||||
body,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(uri, "http://localhost:50051");
|
||||
assert_eq!(file_descriptor_set.unwrap().to_str().unwrap(), "desc.bin");
|
||||
assert_eq!(body, serde_json::json!({}));
|
||||
assert_eq!(headers[0], ("auth".to_string(), "bearer".to_string()));
|
||||
}
|
||||
_ => panic!("Expected Call command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_command_reflection() {
|
||||
let args = vec!["granc", "list", "--uri", "http://localhost:50051"];
|
||||
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
|
||||
|
||||
match cli.command {
|
||||
Commands::List { source } => {
|
||||
assert_eq!(source.uri.unwrap(), "http://localhost:50051");
|
||||
assert!(source.file_descriptor_set.is_none());
|
||||
}
|
||||
_ => panic!("Expected List command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_command_offline() {
|
||||
let args = vec!["granc", "list", "--file-descriptor-set", "desc.bin"];
|
||||
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
|
||||
|
||||
match cli.command {
|
||||
Commands::List { source } => {
|
||||
assert_eq!(
|
||||
source.file_descriptor_set.unwrap().to_str().unwrap(),
|
||||
"desc.bin"
|
||||
);
|
||||
assert!(source.uri.is_none());
|
||||
}
|
||||
_ => panic!("Expected List command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_describe_command() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"describe",
|
||||
"helloworld.Greeter",
|
||||
"--uri",
|
||||
"http://localhost:50051",
|
||||
];
|
||||
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
|
||||
|
||||
match cli.command {
|
||||
Commands::Describe { symbol, source } => {
|
||||
assert_eq!(symbol, "helloworld.Greeter");
|
||||
assert!(source.uri.is_some());
|
||||
}
|
||||
_ => panic!("Expected Describe command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_doc_command_reflection() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"doc",
|
||||
"my.package.Service",
|
||||
"--uri",
|
||||
"http://localhost:50051",
|
||||
"--output",
|
||||
"./docs",
|
||||
];
|
||||
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
|
||||
|
||||
match cli.command {
|
||||
Commands::Doc {
|
||||
symbol,
|
||||
source,
|
||||
output,
|
||||
} => {
|
||||
assert_eq!(symbol, "my.package.Service");
|
||||
assert_eq!(source.uri.unwrap(), "http://localhost:50051");
|
||||
assert_eq!(output.to_str().unwrap(), "./docs");
|
||||
}
|
||||
_ => panic!("Expected Doc command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_doc_command_offline() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"doc",
|
||||
"my.package.Service",
|
||||
"--file-descriptor-set",
|
||||
"descriptors.bin",
|
||||
"-o",
|
||||
"./docs",
|
||||
];
|
||||
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
|
||||
|
||||
match cli.command {
|
||||
Commands::Doc {
|
||||
symbol,
|
||||
source,
|
||||
output,
|
||||
} => {
|
||||
assert_eq!(symbol, "my.package.Service");
|
||||
assert_eq!(
|
||||
source.file_descriptor_set.unwrap().to_str().unwrap(),
|
||||
"descriptors.bin"
|
||||
);
|
||||
assert_eq!(output.to_str().unwrap(), "./docs");
|
||||
}
|
||||
_ => panic!("Expected Doc command"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Failure Cases ---
|
||||
|
||||
#[test]
|
||||
fn test_fail_invalid_json_body() {
|
||||
let args = vec!["granc", "call", "s/m", "-u", "x", "--body", "{invalid_json"];
|
||||
let err = Cli::try_parse_from(&args).unwrap_err();
|
||||
// Should verify that the error comes from the body parser
|
||||
assert!(err.to_string().contains("Invalid JSON"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fail_invalid_endpoint_format() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"call",
|
||||
"OnlyServiceNoMethod", // Missing '/'
|
||||
"-u",
|
||||
"x",
|
||||
"-b",
|
||||
"{}",
|
||||
];
|
||||
let err = Cli::try_parse_from(&args).unwrap_err();
|
||||
assert!(err.to_string().contains("Invalid endpoint format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fail_list_requires_source() {
|
||||
let args = vec!["granc", "list"];
|
||||
let err = Cli::try_parse_from(&args).unwrap_err();
|
||||
// Clap error for missing required arguments in group
|
||||
assert!(err.kind() == clap::error::ErrorKind::MissingRequiredArgument);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fail_list_mutual_exclusion() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"list",
|
||||
"--uri",
|
||||
"http://host",
|
||||
"--file-descriptor-set",
|
||||
"file.bin",
|
||||
];
|
||||
let err = Cli::try_parse_from(&args).unwrap_err();
|
||||
// Clap error for argument conflict
|
||||
assert!(err.kind() == clap::error::ErrorKind::ArgumentConflict);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fail_describe_mutual_exclusion() {
|
||||
let args = vec![
|
||||
"granc",
|
||||
"describe",
|
||||
"Symbol",
|
||||
"-u",
|
||||
"http://host",
|
||||
"-f",
|
||||
"file.bin",
|
||||
];
|
||||
let err = Cli::try_parse_from(&args).unwrap_err();
|
||||
assert!(err.kind() == clap::error::ErrorKind::ArgumentConflict);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
//! # gRPC Client Layer
|
||||
//!
|
||||
//! This module handles the low-level networking and `tonic` client construction.
|
||||
//! It is decoupled from the descriptor logic and strictly focuses on sending
|
||||
//! requests and receiving responses using the `JsonCodec`.
|
||||
//!
|
||||
//! # Error Handling
|
||||
//!
|
||||
//! - **`ClientError`**: Represents transport errors (connection refused, DNS resolution),
|
||||
//! configuration errors (invalid URI), or usage errors (invalid headers).
|
||||
//! - **`tonic::Status`**: Represents a successful HTTP interaction where the gRPC server
|
||||
//! returned an error code (e.g., `NOT_FOUND`, `UNAUTHENTICATED`).
|
||||
//!
|
||||
//! The methods in `GrpcClient` separate these two types of errors by returning
|
||||
//! `Result<Result<T, Status>, ClientError>`.
|
||||
|
||||
use crate::codec::JsonCodec;
|
||||
use futures_util::Stream;
|
||||
use prost_reflect::MethodDescriptor;
|
||||
use std::str::FromStr;
|
||||
use thiserror::Error;
|
||||
use tonic::{
|
||||
Request,
|
||||
client::Grpc,
|
||||
metadata::{
|
||||
MetadataKey, MetadataValue,
|
||||
errors::{InvalidMetadataKey, InvalidMetadataValue},
|
||||
},
|
||||
transport::Channel,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_test;
|
||||
|
||||
/// Represents failures that occur *before* or during the establishment of the network call,
|
||||
/// or protocol violations that prevent a response.
|
||||
#[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}'")]
|
||||
ClientNotReady(tonic::transport::Error),
|
||||
#[error("Invalid metadata (header) key '{key}': '{source}'")]
|
||||
InvalidMetadataKey {
|
||||
key: String,
|
||||
source: InvalidMetadataKey,
|
||||
},
|
||||
#[error("Invalid metadata (header) value for key '{key}': '{source}'")]
|
||||
InvalidMetadataValue {
|
||||
key: String,
|
||||
source: InvalidMetadataValue,
|
||||
},
|
||||
}
|
||||
|
||||
/// A generic gRPC client that uses dynamic dispatch via `prost-reflect`.
|
||||
pub struct GrpcClient {
|
||||
channel: Channel,
|
||||
}
|
||||
|
||||
impl GrpcClient {
|
||||
/// Connects to the specified gRPC server address.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `addr` - The URI of the server (e.g., `http://localhost:50051`).
|
||||
pub async fn connect(addr: &str) -> Result<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 { channel })
|
||||
}
|
||||
|
||||
/// Performs a Unary gRPC call (Single Request -> Single Response).
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Ok(Value))` - Successful RPC execution.
|
||||
/// * `Ok(Err(Status))` - RPC executed, but server returned an error.
|
||||
/// * `Err(ClientError)` - Failed to send request or connect.
|
||||
pub async fn unary(
|
||||
&self,
|
||||
method: MethodDescriptor,
|
||||
payload: serde_json::Value,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<Result<serde_json::Value, tonic::Status>, ClientError> {
|
||||
let mut client = Grpc::new(self.channel.clone());
|
||||
client.ready().await.map_err(ClientError::ClientNotReady)?;
|
||||
|
||||
let codec = JsonCodec::new(method.input(), method.output());
|
||||
let path = http_path(&method);
|
||||
let request = build_request(payload, headers)?;
|
||||
|
||||
match client.unary(request, path, codec).await {
|
||||
Ok(response) => Ok(Ok(response.into_inner())),
|
||||
Err(status) => Ok(Err(status)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a Server Streaming gRPC call (Single Request -> Stream of Responses).
|
||||
pub async fn server_streaming(
|
||||
&self,
|
||||
method: MethodDescriptor,
|
||||
payload: serde_json::Value,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<
|
||||
Result<impl Stream<Item = Result<serde_json::Value, tonic::Status>>, tonic::Status>,
|
||||
ClientError,
|
||||
> {
|
||||
let mut client = Grpc::new(self.channel.clone());
|
||||
client.ready().await.map_err(ClientError::ClientNotReady)?;
|
||||
|
||||
let codec = JsonCodec::new(method.input(), method.output());
|
||||
let path = http_path(&method);
|
||||
let request = build_request(payload, headers)?;
|
||||
|
||||
match client.server_streaming(request, path, codec).await {
|
||||
Ok(response) => Ok(Ok(response.into_inner())),
|
||||
Err(status) => Ok(Err(status)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a Client Streaming gRPC call (Stream of Requests -> Single Response).
|
||||
pub async fn client_streaming(
|
||||
&self,
|
||||
method: MethodDescriptor,
|
||||
payload_stream: impl Stream<Item = serde_json::Value> + Send + 'static,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<Result<serde_json::Value, tonic::Status>, ClientError> {
|
||||
let mut client = Grpc::new(self.channel.clone());
|
||||
client.ready().await.map_err(ClientError::ClientNotReady)?;
|
||||
|
||||
let codec = JsonCodec::new(method.input(), method.output());
|
||||
let path = http_path(&method);
|
||||
let request = build_request(payload_stream, headers)?;
|
||||
|
||||
match client.client_streaming(request, path, codec).await {
|
||||
Ok(response) => Ok(Ok(response.into_inner())),
|
||||
Err(status) => Ok(Err(status)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a Bidirectional Streaming gRPC call (Stream of Requests -> Stream of Responses).
|
||||
pub async fn bidirectional_streaming(
|
||||
&self,
|
||||
method: MethodDescriptor,
|
||||
payload_stream: impl Stream<Item = serde_json::Value> + Send + 'static,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<
|
||||
Result<impl Stream<Item = Result<serde_json::Value, tonic::Status>>, tonic::Status>,
|
||||
ClientError,
|
||||
> {
|
||||
let mut client = Grpc::new(self.channel.clone());
|
||||
client.ready().await.map_err(ClientError::ClientNotReady)?;
|
||||
|
||||
let codec = JsonCodec::new(method.input(), method.output());
|
||||
let path = http_path(&method);
|
||||
let request = build_request(payload_stream, headers)?;
|
||||
|
||||
match client.streaming(request, path, codec).await {
|
||||
Ok(response) => Ok(Ok(response.into_inner())),
|
||||
Err(status) => Ok(Err(status)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn http_path(method: &MethodDescriptor) -> http::uri::PathAndQuery {
|
||||
let path = format!("/{}/{}", method.parent_service().full_name(), method.name());
|
||||
http::uri::PathAndQuery::from_str(&path).expect("valid gRPC path")
|
||||
}
|
||||
|
||||
fn build_request<T>(payload: T, headers: Vec<(String, String)>) -> Result<Request<T>, ClientError> {
|
||||
let mut request = Request::new(payload);
|
||||
for (k, v) in headers {
|
||||
let key = MetadataKey::from_str(&k).map_err(|source| ClientError::InvalidMetadataKey {
|
||||
key: k.clone(),
|
||||
source,
|
||||
})?;
|
||||
let val = MetadataValue::from_str(&v)
|
||||
.map_err(|source| ClientError::InvalidMetadataValue { key: k, source })?;
|
||||
request.metadata_mut().insert(key, val);
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
use crate::{client::GrpcClient, descriptor::DescriptorRegistry};
|
||||
use echo_service::EchoServiceServer;
|
||||
use echo_service::FILE_DESCRIPTOR_SET;
|
||||
use echo_service_impl::EchoServiceImpl;
|
||||
use tokio_stream::StreamExt;
|
||||
use tonic::transport::Server;
|
||||
|
||||
mod echo_service_impl;
|
||||
|
||||
async fn spawn_server() -> String {
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
|
||||
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
Server::builder()
|
||||
.add_service(EchoServiceServer::new(EchoServiceImpl))
|
||||
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
format!("http://{}", addr)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unary() {
|
||||
let url = spawn_server().await;
|
||||
|
||||
let registry = DescriptorRegistry::from_bytes(FILE_DESCRIPTOR_SET).unwrap();
|
||||
let method = registry
|
||||
.fetch_method_descriptor("echo.EchoService/UnaryEcho")
|
||||
.unwrap();
|
||||
|
||||
let client = GrpcClient::connect(&url).await.unwrap();
|
||||
|
||||
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 url = spawn_server().await;
|
||||
|
||||
let registry = DescriptorRegistry::from_bytes(FILE_DESCRIPTOR_SET).unwrap();
|
||||
|
||||
let method = registry
|
||||
.fetch_method_descriptor("echo.EchoService/ServerStreamingEcho")
|
||||
.unwrap();
|
||||
|
||||
let client = GrpcClient::connect(&url).await.unwrap();
|
||||
|
||||
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 url = spawn_server().await;
|
||||
|
||||
let registry = DescriptorRegistry::from_bytes(FILE_DESCRIPTOR_SET).unwrap();
|
||||
let method = registry
|
||||
.fetch_method_descriptor("echo.EchoService/ClientStreamingEcho")
|
||||
.unwrap();
|
||||
|
||||
let client = GrpcClient::connect(&url).await.unwrap();
|
||||
|
||||
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 url = spawn_server().await;
|
||||
|
||||
let registry = DescriptorRegistry::from_bytes(FILE_DESCRIPTOR_SET).unwrap();
|
||||
let method = registry
|
||||
.fetch_method_descriptor("echo.EchoService/BidirectionalEcho")
|
||||
.unwrap();
|
||||
|
||||
let client = GrpcClient::connect(&url).await.unwrap();
|
||||
|
||||
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,68 +0,0 @@
|
|||
//! # Descriptor Registry
|
||||
//!
|
||||
//! This module handles the loading and querying of Protobuf `FileDescriptorSet`s.
|
||||
//! It acts as a database of schema definitions, allowing the application to
|
||||
//! resolve service and method names into `MethodDescriptor` objects required
|
||||
//! for reflection.
|
||||
|
||||
use prost_reflect::{DescriptorPool, MethodDescriptor};
|
||||
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),
|
||||
#[error("Invalid method path. Expected format 'package.Service/Method', got '{0}'")]
|
||||
InvalidFormat(String),
|
||||
}
|
||||
|
||||
/// A registry that holds loaded Protobuf definitions and allows looking up
|
||||
/// services and methods by name.
|
||||
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 })
|
||||
}
|
||||
|
||||
/// Loads a FileDescriptorSet from a file on disk and builds the registry.
|
||||
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 full method path (e.g., "my.package.MyService/MyMethod")
|
||||
/// into a MethodDescriptor.
|
||||
pub fn fetch_method_descriptor(
|
||||
&self,
|
||||
method_path: &str,
|
||||
) -> Result<MethodDescriptor, DescriptorError> {
|
||||
let (service_name, method_name) = method_path
|
||||
.split_once('/')
|
||||
.ok_or_else(|| DescriptorError::InvalidFormat(method_path.to_string()))?;
|
||||
|
||||
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()))
|
||||
}
|
||||
}
|
||||
2
granc/src/docgen.rs
Normal file
2
granc/src/docgen.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod markdown;
|
||||
mod package;
|
||||
185
granc/src/docgen/markdown.rs
Normal file
185
granc/src/docgen/markdown.rs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
use super::package::{Package, Packages};
|
||||
use crate::formatter::FormattedString;
|
||||
use granc_core::prost_reflect::{EnumDescriptor, Kind, MessageDescriptor, ServiceDescriptor};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn generate(output_dir: PathBuf, service: ServiceDescriptor) -> std::io::Result<()> {
|
||||
// Disable colors for plain text generation
|
||||
colored::control::set_override(false);
|
||||
|
||||
if !output_dir.exists() {
|
||||
fs::create_dir_all(&output_dir)?;
|
||||
}
|
||||
|
||||
let packages = Packages::from(service.clone());
|
||||
|
||||
for package in packages.values() {
|
||||
let filename = format!("{}.md", package.name);
|
||||
let path = output_dir.join(&filename);
|
||||
|
||||
let out = generate_package_file(package)?;
|
||||
|
||||
fs::write(path, out)?;
|
||||
println!("Generated: {}", filename);
|
||||
}
|
||||
|
||||
let path = output_dir.join("index.md");
|
||||
let out = generate_index(&service, &packages)?;
|
||||
fs::write(path, out)?;
|
||||
println!("Generated: index.md");
|
||||
|
||||
// Restore colors
|
||||
colored::control::unset_override();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_index(
|
||||
entry_service: &ServiceDescriptor,
|
||||
packages: &Packages,
|
||||
) -> std::io::Result<String> {
|
||||
let mut out = String::new();
|
||||
|
||||
out.push_str("# Documentation Index\n\n\n");
|
||||
|
||||
let svc_package = entry_service.package_name();
|
||||
let svc_link = format!("{}.md#{}", svc_package, entry_service.name());
|
||||
|
||||
out.push_str("## Service\n\n");
|
||||
out.push_str(&format!("- [**{}**]({})\n", entry_service.name(), svc_link));
|
||||
|
||||
out.push_str("\n## Packages\n\n");
|
||||
|
||||
// Collect package names (Google packages included)
|
||||
let mut package_names: Vec<_> = packages.names().collect();
|
||||
package_names.sort();
|
||||
|
||||
if package_names.is_empty() {
|
||||
out.push_str("*None*\n");
|
||||
} else {
|
||||
for name in package_names {
|
||||
out.push_str(&format!("- [{}]({}.md)\n", name, name));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn generate_package_file(package: &Package) -> std::io::Result<String> {
|
||||
let mut out = String::new();
|
||||
|
||||
let mut services = package.services.clone();
|
||||
services.sort_by(|a, b| a.name().cmp(b.name()));
|
||||
|
||||
for service in services {
|
||||
write_anchor(&mut out, service.name());
|
||||
out.push_str(&format!("## {}\n\n", service.name()));
|
||||
write_service_content(&mut out, &service);
|
||||
out.push_str("---\n\n");
|
||||
}
|
||||
|
||||
let mut messages = package.messages.clone();
|
||||
messages.sort_by(|a, b| a.name().cmp(b.name()));
|
||||
|
||||
for message in messages {
|
||||
write_anchor(&mut out, message.name());
|
||||
out.push_str(&format!("## {}\n\n", message.name()));
|
||||
write_message_content(&mut out, &message);
|
||||
out.push_str("---\n\n");
|
||||
}
|
||||
|
||||
let mut enums = package.enums.clone();
|
||||
enums.sort_by(|a, b| a.name().cmp(b.name()));
|
||||
|
||||
for enum_desc in enums {
|
||||
write_anchor(&mut out, enum_desc.name());
|
||||
out.push_str(&format!("## {}\n\n", enum_desc.name()));
|
||||
write_enum_content(&mut out, &enum_desc);
|
||||
out.push_str("---\n\n");
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn write_anchor(out: &mut String, name: &str) {
|
||||
out.push_str(&format!("<a id=\"{name}\"></a>\n"));
|
||||
}
|
||||
|
||||
fn write_service_content(out: &mut String, service: &ServiceDescriptor) {
|
||||
out.push_str("### Definition\n\n```protobuf\n");
|
||||
out.push_str(&format!("package {};\n\n", service.package_name()));
|
||||
out.push_str(&FormattedString::from(service.clone()).0);
|
||||
out.push_str("\n```\n\n");
|
||||
|
||||
out.push_str("### Methods\n\n");
|
||||
for method in service.methods() {
|
||||
out.push_str(&format!("#### `{}`\n\n", method.name()));
|
||||
|
||||
let input = method.input();
|
||||
let output = method.output();
|
||||
|
||||
let input_link = resolve_link(input.package_name(), input.name());
|
||||
let output_link = resolve_link(output.package_name(), output.name());
|
||||
|
||||
out.push_str(&format!("- Request: [{}]({})\n", input.name(), input_link));
|
||||
out.push_str(&format!(
|
||||
"- Response: [{}]({})\n",
|
||||
output.name(),
|
||||
output_link
|
||||
));
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
fn write_message_content(out: &mut String, message: &MessageDescriptor) {
|
||||
out.push_str("### Definition\n\n```protobuf\n");
|
||||
out.push_str(&format!("package {};\n\n", message.package_name()));
|
||||
out.push_str(&FormattedString::from(message.clone()).0);
|
||||
out.push_str("\n```\n\n");
|
||||
|
||||
out.push_str("### Dependencies\n\n");
|
||||
let mut has_deps = false;
|
||||
|
||||
for field in message.fields() {
|
||||
match field.kind() {
|
||||
Kind::Message(m) => {
|
||||
has_deps = true;
|
||||
let link = resolve_link(m.package_name(), m.name());
|
||||
out.push_str(&format!(
|
||||
"- Field `{}`: [{}]({})\n",
|
||||
field.name(),
|
||||
m.name(),
|
||||
link
|
||||
));
|
||||
}
|
||||
Kind::Enum(e) => {
|
||||
has_deps = true;
|
||||
let link = resolve_link(e.package_name(), e.name());
|
||||
out.push_str(&format!(
|
||||
"- Field `{}`: [{}]({})\n",
|
||||
field.name(),
|
||||
e.name(),
|
||||
link
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_deps {
|
||||
out.push_str("*None*\n");
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
fn write_enum_content(out: &mut String, enum_desc: &EnumDescriptor) {
|
||||
out.push_str("### Definition\n\n```protobuf\n");
|
||||
out.push_str(&format!("package {};\n\n", enum_desc.package_name()));
|
||||
out.push_str(&FormattedString::from(enum_desc.clone()).0);
|
||||
out.push_str("\n```\n\n");
|
||||
}
|
||||
|
||||
fn resolve_link(package: &str, name: &str) -> String {
|
||||
// Always link to local file + anchor
|
||||
format!("{}.md#{}", package, name)
|
||||
}
|
||||
286
granc/src/docgen/package.rs
Normal file
286
granc/src/docgen/package.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
//! # Package
|
||||
//!
|
||||
//! This module defines two types that provide all the information needed to generate documentation about a protobuffer project:
|
||||
//!
|
||||
//! + [`Package`]: Contains the required data for other modules to be able to generate documentation about a single package.
|
||||
//! + [`Packages`]: A collection of packages. It can be constructed from a single Service descriptor.
|
||||
use granc_core::{
|
||||
client::Descriptor,
|
||||
prost_reflect::{EnumDescriptor, Kind, MessageDescriptor, ServiceDescriptor},
|
||||
};
|
||||
use std::collections::{HashMap, hash_map::Keys};
|
||||
|
||||
/// Represents a single protobuffer package.
|
||||
///
|
||||
/// It contains all the services, messages and enums described in the package.
|
||||
pub(crate) struct Package {
|
||||
pub name: String,
|
||||
pub services: Vec<ServiceDescriptor>,
|
||||
pub messages: Vec<MessageDescriptor>,
|
||||
pub enums: Vec<EnumDescriptor>,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
fn new(name: String) -> Self {
|
||||
Package {
|
||||
name,
|
||||
services: vec![],
|
||||
messages: vec![],
|
||||
enums: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn push_descriptor(&mut self, descriptor: Descriptor) {
|
||||
match descriptor {
|
||||
Descriptor::MessageDescriptor(v) => self.messages.push(v),
|
||||
Descriptor::ServiceDescriptor(v) => self.services.push(v),
|
||||
Descriptor::EnumDescriptor(v) => self.enums.push(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Descriptor> for Package {
|
||||
fn from(value: Descriptor) -> Self {
|
||||
let package_name = value.package_name().to_string();
|
||||
let mut package = Package::new(package_name);
|
||||
package.push_descriptor(value);
|
||||
package
|
||||
}
|
||||
}
|
||||
|
||||
/// A collection of protobuffer packages.
|
||||
/// It can be constructed from a `ServiceDescriptor`.
|
||||
/// Packages are constructed after building a graph of all the descriptor dependencies.
|
||||
/// This graph removes duplication of dependencies and ensures the quality of the information provided by each `Package`.
|
||||
pub(crate) struct Packages(HashMap<String, Package>);
|
||||
|
||||
impl Packages {
|
||||
pub fn values(&self) -> std::collections::hash_map::Values<'_, String, Package> {
|
||||
self.0.values()
|
||||
}
|
||||
|
||||
pub fn names(&self) -> Keys<'_, String, Package> {
|
||||
self.0.keys()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ServiceDescriptor> for Packages {
|
||||
fn from(value: ServiceDescriptor) -> Self {
|
||||
let mut descriptors = collect_service_dependencies(&value);
|
||||
|
||||
descriptors.insert(
|
||||
value.full_name().to_string(),
|
||||
Descriptor::ServiceDescriptor(value),
|
||||
);
|
||||
|
||||
let packages = group_descriptors_by_package(descriptors.into_values());
|
||||
Packages(packages)
|
||||
}
|
||||
}
|
||||
|
||||
fn group_descriptors_by_package(
|
||||
descriptors: impl IntoIterator<Item = Descriptor>,
|
||||
) -> HashMap<String, Package> {
|
||||
descriptors
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut acc, descriptor| {
|
||||
let package_name = descriptor.package_name();
|
||||
|
||||
match acc.get_mut(package_name) {
|
||||
Some(package) => package.push_descriptor(descriptor),
|
||||
None => {
|
||||
let _ = acc.insert(package_name.to_string(), Package::from(descriptor));
|
||||
}
|
||||
}
|
||||
|
||||
acc
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_service_dependencies(service: &ServiceDescriptor) -> HashMap<String, Descriptor> {
|
||||
service
|
||||
.methods()
|
||||
.flat_map(|m| [m.input(), m.output()])
|
||||
.fold(HashMap::new(), |mut acc, d| {
|
||||
let message_name = d.full_name().to_string();
|
||||
|
||||
if acc.contains_key(&message_name) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.insert(message_name, Descriptor::MessageDescriptor(d.clone()));
|
||||
|
||||
collect_message_dependencies(acc, &d)
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_message_dependencies(
|
||||
descriptors: HashMap<String, Descriptor>,
|
||||
message: &MessageDescriptor,
|
||||
) -> HashMap<String, Descriptor> {
|
||||
message
|
||||
.fields()
|
||||
.fold(descriptors, |mut acc, field| match field.kind() {
|
||||
Kind::Message(m) => {
|
||||
let message_name = m.full_name().to_string();
|
||||
|
||||
if acc.contains_key(&message_name) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.insert(message_name, Descriptor::MessageDescriptor(m.clone()));
|
||||
|
||||
collect_message_dependencies(acc, &m)
|
||||
}
|
||||
Kind::Enum(e) => {
|
||||
acc.insert(e.full_name().to_string(), Descriptor::EnumDescriptor(e));
|
||||
acc
|
||||
}
|
||||
_ => acc,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use granc_core::prost_reflect::DescriptorPool;
|
||||
use granc_test_support::compiler;
|
||||
|
||||
fn compile_protos(files: &[(&str, &str)]) -> DescriptorPool {
|
||||
let file_descriptor_set = compiler::compile_protos(files);
|
||||
DescriptorPool::from_file_descriptor_set(file_descriptor_set)
|
||||
.expect("Failed to decode descriptor pool")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_package_collection_with_deduplication() {
|
||||
let proto = r#"
|
||||
syntax = "proto3";
|
||||
package test;
|
||||
|
||||
enum Status {
|
||||
UNKNOWN = 0;
|
||||
OK = 1;
|
||||
}
|
||||
|
||||
message Request {
|
||||
Status status = 1;
|
||||
}
|
||||
|
||||
message Response {
|
||||
Status status = 1;
|
||||
}
|
||||
|
||||
service MyService {
|
||||
rpc DoSomething(Request) returns (Response);
|
||||
}
|
||||
"#;
|
||||
|
||||
let pool = compile_protos(&[("test.proto", proto)]);
|
||||
let service = pool
|
||||
.get_service_by_name("test.MyService")
|
||||
.expect("Service not found");
|
||||
|
||||
let packages = Packages::from(service);
|
||||
|
||||
let test_package = packages.0.get("test").expect("Package 'test' missing");
|
||||
|
||||
assert_eq!(test_package.services.len(), 1);
|
||||
assert_eq!(test_package.services[0].name(), "MyService");
|
||||
|
||||
assert_eq!(test_package.messages.len(), 2);
|
||||
|
||||
let msg_names: Vec<_> = test_package.messages.iter().map(|m| m.name()).collect();
|
||||
assert!(msg_names.contains(&"Request"));
|
||||
assert!(msg_names.contains(&"Response"));
|
||||
|
||||
assert_eq!(
|
||||
test_package.enums.len(),
|
||||
1,
|
||||
"Enum should appear exactly once"
|
||||
);
|
||||
assert_eq!(test_package.enums[0].name(), "Status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circular_dependency_handling() {
|
||||
let proto = r#"
|
||||
syntax = "proto3";
|
||||
package cycle;
|
||||
|
||||
message NodeA {
|
||||
NodeB child = 1;
|
||||
}
|
||||
|
||||
message NodeB {
|
||||
NodeA parent = 1;
|
||||
}
|
||||
|
||||
service Cycler {
|
||||
rpc Cycle(NodeA) returns (NodeA);
|
||||
}
|
||||
"#;
|
||||
|
||||
let pool = compile_protos(&[("cycle.proto", proto)]);
|
||||
let service = pool
|
||||
.get_service_by_name("cycle.Cycler")
|
||||
.expect("Service not found");
|
||||
|
||||
let packages = Packages::from(service);
|
||||
|
||||
let pkg = packages.0.get("cycle").expect("Package 'cycle' missing");
|
||||
|
||||
assert_eq!(pkg.messages.len(), 2);
|
||||
assert_eq!(pkg.services.len(), 1);
|
||||
assert_eq!(pkg.enums.len(), 0);
|
||||
|
||||
let names: Vec<_> = pkg.messages.iter().map(|m| m.name()).collect();
|
||||
|
||||
assert!(names.contains(&"NodeA"));
|
||||
assert!(names.contains(&"NodeB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_file_imports() {
|
||||
let common_proto = r#"
|
||||
syntax = "proto3";
|
||||
package common;
|
||||
|
||||
message Shared {
|
||||
string id = 1;
|
||||
}
|
||||
"#;
|
||||
|
||||
let app_proto = r#"
|
||||
syntax = "proto3";
|
||||
package app;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
service AppService {
|
||||
rpc Get(common.Shared) returns (common.Shared);
|
||||
}
|
||||
"#;
|
||||
|
||||
let pool = compile_protos(&[("common.proto", common_proto), ("app.proto", app_proto)]);
|
||||
|
||||
let service = pool
|
||||
.get_service_by_name("app.AppService")
|
||||
.expect("Service not found");
|
||||
|
||||
let packages = Packages::from(service);
|
||||
|
||||
let app_pkg = packages.0.get("app").expect("Package 'app' missing");
|
||||
|
||||
assert_eq!(app_pkg.services.len(), 1);
|
||||
assert_eq!(app_pkg.messages.len(), 0);
|
||||
assert_eq!(app_pkg.enums.len(), 0);
|
||||
|
||||
let common_pkg = packages.0.get("common").expect("Package 'common' missing");
|
||||
|
||||
assert_eq!(common_pkg.messages.len(), 1);
|
||||
assert_eq!(common_pkg.messages[0].name(), "Shared");
|
||||
assert_eq!(common_pkg.services.len(), 0);
|
||||
assert_eq!(common_pkg.enums.len(), 0);
|
||||
}
|
||||
}
|
||||
265
granc/src/formatter.rs
Normal file
265
granc/src/formatter.rs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
use colored::*;
|
||||
use granc_core::{
|
||||
client::{Descriptor, online, online_without_reflection},
|
||||
prost_reflect::{
|
||||
self, EnumDescriptor, Kind, MessageDescriptor, MethodDescriptor, ServiceDescriptor,
|
||||
},
|
||||
tonic::{self, Status},
|
||||
};
|
||||
use std::fmt::Display;
|
||||
|
||||
/// A wrapper struct for a formatted, colored string.
|
||||
///
|
||||
/// Implements `Display` so it can be printed directly.
|
||||
pub struct FormattedString(pub String);
|
||||
|
||||
pub struct ServiceList(pub Vec<String>);
|
||||
|
||||
pub struct GenericError<T: Display>(pub &'static str, pub T);
|
||||
|
||||
impl std::fmt::Display for FormattedString {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
writeln!(f)?;
|
||||
writeln!(f, "{}", self.0)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Value> for FormattedString {
|
||||
fn from(value: serde_json::Value) -> Self {
|
||||
FormattedString(serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Status> for FormattedString {
|
||||
fn from(status: Status) -> Self {
|
||||
FormattedString(format!(
|
||||
"{} code={:?} message={:?}",
|
||||
"gRPC Failed:".red().bold(),
|
||||
status.code(),
|
||||
status.message()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<Result<serde_json::Value, tonic::Status>>> for FormattedString {
|
||||
fn from(values: Vec<Result<serde_json::Value, tonic::Status>>) -> Self {
|
||||
let mut s = String::new();
|
||||
for elem in values {
|
||||
match elem {
|
||||
Ok(val) => s.push_str(&FormattedString::from(val).0),
|
||||
Err(status) => s.push_str(&FormattedString::from(status).0),
|
||||
}
|
||||
}
|
||||
FormattedString(s)
|
||||
}
|
||||
}
|
||||
|
||||
// Error from Reflection-based calls
|
||||
impl From<online::DynamicCallError> for FormattedString {
|
||||
fn from(err: online::DynamicCallError) -> Self {
|
||||
FormattedString(format!("{}\n\n'{}'", "Call Failed:".red().bold(), err))
|
||||
}
|
||||
}
|
||||
|
||||
// Error from FileDescriptor-based calls
|
||||
impl From<online_without_reflection::DynamicCallError> for FormattedString {
|
||||
fn from(err: online_without_reflection::DynamicCallError) -> Self {
|
||||
FormattedString(format!("{}\n\n'{}'", "Call Failed:".red().bold(), err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<prost_reflect::DescriptorError> for FormattedString {
|
||||
fn from(err: prost_reflect::DescriptorError) -> Self {
|
||||
FormattedString(format!(
|
||||
"{}\n\n'{}'",
|
||||
"Failed to parse file descriptor:".red().bold(),
|
||||
err
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for FormattedString {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
FormattedString(format!(
|
||||
"{}\n\n'{}'",
|
||||
"Failed to read file:".red().bold(),
|
||||
err
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Display> From<GenericError<T>> for FormattedString {
|
||||
fn from(GenericError(msg, err): GenericError<T>) -> Self {
|
||||
FormattedString(format!("{}:\n\n'{}'", msg.red().bold(), err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<online::ClientConnectError> for FormattedString {
|
||||
fn from(err: online::ClientConnectError) -> Self {
|
||||
FormattedString(format!("{}\n\n'{}'", "Connection Error:".red().bold(), err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<online::GetDescriptorError> for FormattedString {
|
||||
fn from(err: online::GetDescriptorError) -> Self {
|
||||
FormattedString(format!(
|
||||
"{}\n\n'{}'",
|
||||
"Symbol Lookup Failed:".red().bold(),
|
||||
err
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ServiceList> for FormattedString {
|
||||
fn from(ServiceList(services): ServiceList) -> Self {
|
||||
if services.is_empty() {
|
||||
return FormattedString("No services found.".yellow().to_string());
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str("Available Services:\n");
|
||||
for svc in services {
|
||||
out.push_str(&format!(" - {}\n", svc.green()));
|
||||
}
|
||||
FormattedString(out.trim_end().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Descriptor> for FormattedString {
|
||||
fn from(value: Descriptor) -> Self {
|
||||
match value {
|
||||
Descriptor::MessageDescriptor(d) => FormattedString::from(d),
|
||||
Descriptor::ServiceDescriptor(d) => FormattedString::from(d),
|
||||
Descriptor::EnumDescriptor(d) => FormattedString::from(d),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ServiceDescriptor> for FormattedString {
|
||||
fn from(service: ServiceDescriptor) -> Self {
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
"{} {} {{\n",
|
||||
"service".cyan(),
|
||||
service.name().green()
|
||||
));
|
||||
|
||||
for method in service.methods() {
|
||||
out.push_str(" ");
|
||||
// Reuse the From<MethodDescriptor> implementation
|
||||
let method_fmt = FormattedString::from(method);
|
||||
out.push_str(&method_fmt.0);
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
out.push('}');
|
||||
FormattedString(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MethodDescriptor> for FormattedString {
|
||||
fn from(method: MethodDescriptor) -> Self {
|
||||
let input_stream = if method.is_client_streaming() {
|
||||
format!("{} ", "stream".cyan())
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
let output_stream = if method.is_server_streaming() {
|
||||
format!("{} ", "stream".cyan())
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
FormattedString(format!(
|
||||
"{} {}({}{}) {} ({}{});",
|
||||
"rpc".cyan(),
|
||||
method.name().green(),
|
||||
input_stream,
|
||||
method.input().full_name().yellow(),
|
||||
"returns".cyan(),
|
||||
output_stream,
|
||||
method.output().full_name().yellow()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MessageDescriptor> for FormattedString {
|
||||
fn from(message: MessageDescriptor) -> Self {
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
"{} {} {{\n",
|
||||
"message".cyan(),
|
||||
message.name().green()
|
||||
));
|
||||
|
||||
for field in message.fields() {
|
||||
let label = if field.is_list() {
|
||||
format!("{} ", "repeated".cyan())
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
let type_name = match field.kind() {
|
||||
Kind::Double => "double".yellow(),
|
||||
Kind::Float => "float".yellow(),
|
||||
Kind::Int32 => "int32".yellow(),
|
||||
Kind::Int64 => "int64".yellow(),
|
||||
Kind::Uint32 => "uint32".yellow(),
|
||||
Kind::Uint64 => "uint64".yellow(),
|
||||
Kind::Sint32 => "sint32".yellow(),
|
||||
Kind::Sint64 => "sint64".yellow(),
|
||||
Kind::Fixed32 => "fixed32".yellow(),
|
||||
Kind::Fixed64 => "fixed64".yellow(),
|
||||
Kind::Sfixed32 => "sfixed32".yellow(),
|
||||
Kind::Sfixed64 => "sfixed64".yellow(),
|
||||
Kind::Bool => "bool".yellow(),
|
||||
Kind::String => "string".yellow(),
|
||||
Kind::Bytes => "bytes".yellow(),
|
||||
Kind::Message(m) => m.full_name().yellow(),
|
||||
Kind::Enum(e) => e.full_name().yellow(),
|
||||
};
|
||||
|
||||
if field.is_map() {
|
||||
out.push_str(&format!(
|
||||
" // map entry: {} {} = {};\n",
|
||||
type_name,
|
||||
field.name(),
|
||||
field.number()
|
||||
));
|
||||
} else {
|
||||
out.push_str(&format!(
|
||||
" {}{}{} {} = {};\n",
|
||||
label,
|
||||
type_name,
|
||||
" ".normal(), // Reset color
|
||||
field.name(),
|
||||
field.number()
|
||||
));
|
||||
}
|
||||
}
|
||||
out.push('}');
|
||||
FormattedString(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EnumDescriptor> for FormattedString {
|
||||
fn from(enum_desc: EnumDescriptor) -> Self {
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
"{} {} {{\n",
|
||||
"enum".cyan(),
|
||||
enum_desc.name().green()
|
||||
));
|
||||
|
||||
for val in enum_desc.values() {
|
||||
out.push_str(&format!(
|
||||
" {} = {};\n",
|
||||
val.name(),
|
||||
val.number().to_string().purple()
|
||||
));
|
||||
}
|
||||
out.push('}');
|
||||
|
||||
FormattedString(out)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,175 +1,168 @@
|
|||
#![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. **Dispatch**: Routes the command to the appropriate handler based on input arguments
|
||||
//! (connecting to server vs loading local file).
|
||||
//! 3. **Execution**: Delegates request processing to `GrancClient`.
|
||||
//! 4. **Presentation**: Formats and prints data.
|
||||
mod cli;
|
||||
mod docgen;
|
||||
mod formatter;
|
||||
|
||||
/// # Granc CLI Entry Point
|
||||
///
|
||||
/// The main module orchestrates the CLI workflow:
|
||||
/// 1. Parses command-line arguments.
|
||||
/// 2. Loads the Protobuf descriptor registry.
|
||||
/// 3. Connects to the gRPC server.
|
||||
/// 4. Dispatches the request to the appropriate method type (Unary, Streaming, etc.).
|
||||
use clap::Parser;
|
||||
use client::GrpcClient;
|
||||
use descriptor::DescriptorRegistry;
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use prost_reflect::MethodDescriptor;
|
||||
use std::path::PathBuf;
|
||||
use cli::{Cli, Commands, Source};
|
||||
use formatter::{FormattedString, GenericError};
|
||||
use futures_util::StreamExt;
|
||||
use granc_core::client::{Descriptor, DynamicRequest, DynamicResponse, GrancClient};
|
||||
use std::process;
|
||||
|
||||
mod client;
|
||||
mod codec;
|
||||
mod descriptor;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "granc", version, about = "Dynamic gRPC CLI")]
|
||||
struct Cli {
|
||||
#[arg(long, help = "Path to the descriptor set (.bin)")]
|
||||
proto_set: PathBuf,
|
||||
|
||||
#[arg(long, help = "JSON body (Object for Unary, Array for Streaming)")]
|
||||
body: String,
|
||||
|
||||
#[arg(short = 'H', long = "header", value_parser = parse_header)]
|
||||
headers: Vec<(String, String)>,
|
||||
|
||||
#[arg(help = "Server URL (http://host:port)")]
|
||||
url: String,
|
||||
|
||||
#[arg(help = "Method (package.Service/Method)")]
|
||||
method: String,
|
||||
}
|
||||
|
||||
fn parse_header(s: &str) -> Result<(String, String), String> {
|
||||
s.split_once(':')
|
||||
.map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
|
||||
.ok_or_else(|| "Format must be 'key:value'".to_string())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
if let Err(e) = run().await {
|
||||
eprintln!("Error: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> anyhow::Result<()> {
|
||||
let args = Cli::parse();
|
||||
|
||||
let registry = DescriptorRegistry::from_file(&args.proto_set)?;
|
||||
let method = registry.fetch_method_descriptor(&args.method)?;
|
||||
match args.command {
|
||||
Commands::Call {
|
||||
endpoint,
|
||||
uri,
|
||||
body,
|
||||
headers,
|
||||
file_descriptor_set,
|
||||
} => {
|
||||
let response = call(endpoint, uri, body, headers, file_descriptor_set).await;
|
||||
|
||||
let body_json: serde_json::Value =
|
||||
serde_json::from_str(&args.body).map_err(|e| anyhow::anyhow!("Invalid JSON: {}", e))?;
|
||||
let formatted = match response {
|
||||
DynamicResponse::Unary(Ok(value)) => FormattedString::from(value),
|
||||
DynamicResponse::Unary(Err(status)) => FormattedString::from(status),
|
||||
DynamicResponse::Streaming(stream) => {
|
||||
let elems = stream.collect::<Vec<_>>().await;
|
||||
FormattedString::from(elems)
|
||||
}
|
||||
};
|
||||
|
||||
let client = GrpcClient::connect(&args.url).await?;
|
||||
println!("{formatted}")
|
||||
}
|
||||
|
||||
println!("Calling {}...", args.method);
|
||||
Commands::List { source } => {
|
||||
let services = list(source.value()).await;
|
||||
println!(
|
||||
"{}",
|
||||
FormattedString::from(formatter::ServiceList(services))
|
||||
)
|
||||
}
|
||||
|
||||
match (method.is_client_streaming(), method.is_server_streaming()) {
|
||||
(false, false) => handle_unary(client, method, body_json, args.headers).await,
|
||||
(false, true) => handle_server_stream(client, method, body_json, args.headers).await,
|
||||
(true, false) => handle_client_stream(client, method, body_json, args.headers).await,
|
||||
(true, true) => handle_bidirectional_stream(client, method, body_json, args.headers).await,
|
||||
}
|
||||
}
|
||||
Commands::Describe { symbol, source } => {
|
||||
let descriptor = describe(symbol, source.value()).await;
|
||||
println!("{}", FormattedString::from(descriptor))
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
// Add the Doc handler
|
||||
Commands::Doc {
|
||||
symbol,
|
||||
source,
|
||||
output,
|
||||
} => {
|
||||
let descriptor = describe(symbol.clone(), source.value()).await;
|
||||
|
||||
async fn handle_unary(
|
||||
client: GrpcClient,
|
||||
method: MethodDescriptor,
|
||||
body: serde_json::Value,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> anyhow::Result<()> {
|
||||
match client.unary(method, body, headers).await? {
|
||||
Ok(val) => print_json(&val),
|
||||
Err(status) => print_status(status),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let service_descriptor = descriptor
|
||||
.service_descriptor()
|
||||
.cloned()
|
||||
.ok_or(GenericError("The symbol must be a Service", symbol))
|
||||
.unwrap_or_exit();
|
||||
|
||||
async fn handle_server_stream(
|
||||
client: GrpcClient,
|
||||
method: MethodDescriptor,
|
||||
body: serde_json::Value,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> anyhow::Result<()> {
|
||||
match client.server_streaming(method, body, headers).await? {
|
||||
Ok(stream) => print_stream(stream).await,
|
||||
Err(status) => print_status(status),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
docgen::markdown::generate(output, service_descriptor)
|
||||
.map_err(|e| GenericError("Failed to generate docs", e))
|
||||
.unwrap_or_exit();
|
||||
|
||||
async fn handle_client_stream(
|
||||
client: GrpcClient,
|
||||
method: MethodDescriptor,
|
||||
body: serde_json::Value,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> anyhow::Result<()> {
|
||||
let input_stream = json_array_to_stream(body)?;
|
||||
match client
|
||||
.client_streaming(method, input_stream, headers)
|
||||
.await?
|
||||
{
|
||||
Ok(val) => print_json(&val),
|
||||
Err(status) => print_status(status),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_bidirectional_stream(
|
||||
client: GrpcClient,
|
||||
method: MethodDescriptor,
|
||||
body: serde_json::Value,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> anyhow::Result<()> {
|
||||
let input_stream = json_array_to_stream(body)?;
|
||||
match client
|
||||
.bidirectional_streaming(method, input_stream, headers)
|
||||
.await?
|
||||
{
|
||||
Ok(stream) => print_stream(stream).await,
|
||||
Err(status) => print_status(status),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
fn json_array_to_stream(
|
||||
json: serde_json::Value,
|
||||
) -> anyhow::Result<impl Stream<Item = serde_json::Value> + Send + 'static> {
|
||||
match json {
|
||||
serde_json::Value::Array(items) => Ok(tokio_stream::iter(items)),
|
||||
_ => Err(anyhow::anyhow!(
|
||||
"Client streaming requires a JSON Array body"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_json(val: &serde_json::Value) {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(val).unwrap_or_else(|_| val.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
fn print_status(status: tonic::Status) {
|
||||
eprintln!(
|
||||
"gRPC Failed: code={:?} message={:?}",
|
||||
status.code(),
|
||||
status.message()
|
||||
);
|
||||
}
|
||||
|
||||
async fn print_stream(
|
||||
mut stream: impl Stream<Item = Result<serde_json::Value, tonic::Status>> + Unpin,
|
||||
) {
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(val) => print_json(&val),
|
||||
Err(status) => print_status(status),
|
||||
println!("Documentation generated successfully.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn call(
|
||||
endpoint: (String, String),
|
||||
uri: String,
|
||||
body: serde_json::Value,
|
||||
headers: Vec<(String, String)>,
|
||||
file_descriptor_set: Option<std::path::PathBuf>,
|
||||
) -> DynamicResponse {
|
||||
let (service, method) = endpoint;
|
||||
|
||||
let request = DynamicRequest {
|
||||
service,
|
||||
method,
|
||||
body,
|
||||
headers,
|
||||
};
|
||||
|
||||
let mut client = GrancClient::connect(&uri).await.unwrap_or_exit();
|
||||
|
||||
if let Some(path) = file_descriptor_set {
|
||||
let bytes = std::fs::read(path).unwrap_or_exit();
|
||||
let mut client = client.with_file_descriptor(bytes).unwrap_or_exit();
|
||||
client.dynamic(request).await.unwrap_or_exit()
|
||||
} else {
|
||||
client.dynamic(request).await.unwrap_or_exit()
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(source: Source) -> Vec<String> {
|
||||
match source {
|
||||
Source::Uri(uri) => {
|
||||
let mut client = GrancClient::connect(&uri).await.unwrap_or_exit();
|
||||
client
|
||||
.list_services()
|
||||
.await
|
||||
.map_err(|e| GenericError("Failed to list services:", e))
|
||||
.unwrap_or_exit()
|
||||
}
|
||||
|
||||
Source::File(path) => {
|
||||
let fd_bytes = std::fs::read(path).unwrap_or_exit();
|
||||
let client = GrancClient::offline(fd_bytes).unwrap_or_exit();
|
||||
client.list_services()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn describe(symbol: String, source: Source) -> Descriptor {
|
||||
match source {
|
||||
Source::Uri(uri) => {
|
||||
let mut client = GrancClient::connect(&uri).await.unwrap_or_exit();
|
||||
client
|
||||
.get_descriptor_by_symbol(&symbol)
|
||||
.await
|
||||
.unwrap_or_exit()
|
||||
}
|
||||
|
||||
Source::File(path) => {
|
||||
let fd_bytes = std::fs::read(path).unwrap_or_exit();
|
||||
let client = GrancClient::offline(fd_bytes).unwrap_or_exit();
|
||||
client
|
||||
.get_descriptor_by_symbol(&symbol)
|
||||
.ok_or(GenericError("Symbol not found", symbol))
|
||||
.unwrap_or_exit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Utility trait to standardize the way we handle errors in the program
|
||||
trait UnwrapOrExit<T, E> {
|
||||
fn unwrap_or_exit(self) -> T;
|
||||
}
|
||||
|
||||
impl<T, E> UnwrapOrExit<T, E> for Result<T, E>
|
||||
where
|
||||
E: Into<FormattedString>,
|
||||
{
|
||||
fn unwrap_or_exit(self) -> T {
|
||||
match self {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("{}", Into::<FormattedString>::into(e));
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[workspace]
|
||||
# set the path of all the crates to the changelog to the root of the repository
|
||||
changelog_path = "./granc/CHANGELOG.md"
|
||||
changelog_path = "CHANGELOG.md"
|
||||
pr_draft = true
|
||||
pr_labels = ["release"]
|
||||
pr_branch_prefix = "release-"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue