Compare commits

...

55 commits

Author SHA1 Message Date
Victor Martinez Montané
5a69984daa chore: release-plz update (#4)
This is an automated PR generated by [release-plz](https://github.com/MarcoIeni/release-plz) via Woodpecker CI.

Co-authored-by: release-plz-bot <bot@codeberg.org>
Reviewed-on: https://codeberg.org/JasterV/granc/pulls/4
2026-04-24 14:34:11 +02:00
JasterV
85a9fd47bc chore: update dependencies 2026-04-23 01:37:08 +02:00
JasterV
e1c741f5de update README 2026-04-22 18:01:41 +02:00
Victor Martinez Montané
74b4f3aac0 chore: release-plz update (#3)
This is an automated PR generated by [release-plz](https://github.com/MarcoIeni/release-plz) via Woodpecker CI.

Co-authored-by: release-plz-bot <bot@codeberg.org>
Co-authored-by: JasterV <49537445+JasterV@users.noreply.github.com>
Reviewed-on: https://codeberg.org/JasterV/granc/pulls/3
2026-04-22 17:46:20 +02:00
Victor Martinez Montané
288f8a06e3 Merge pull request 'refactor: use streams for streaming responses' (#2) from refactor/granc-core-proper-stream-handling into main
Reviewed-on: https://codeberg.org/JasterV/granc/pulls/2
2026-03-24 00:44:28 +01:00
JasterV
43acb93dd1 refactor: use streams for stream responses 2026-03-24 00:39:47 +01:00
Victor Martinez Montané
69f1115cbe chore: release-plz update (#1)
This is an automated PR generated by [release-plz](https://github.com/MarcoIeni/release-plz) via Woodpecker CI.

Co-authored-by: release-plz-bot <bot@codeberg.org>
Reviewed-on: https://codeberg.org/JasterV/granc/pulls/1
2026-03-09 18:45:49 +01:00
JasterV
23af9fc8a0 refactor: migrate to woodpecker 2026-03-09 17:10:46 +01:00
dependabot[bot]
9ce4f73189
chore(deps): bump the grpc group with 4 updates (#62)
Bumps the grpc group with 4 updates: [tonic](https://github.com/hyperium/tonic), [tonic-prost](https://github.com/hyperium/tonic), [tonic-reflection](https://github.com/hyperium/tonic) and [tonic-prost-build](https://github.com/hyperium/tonic).


Updates `tonic` from 0.14.4 to 0.14.5
- [Release notes](https://github.com/hyperium/tonic/releases)
- [Changelog](https://github.com/hyperium/tonic/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/tonic/compare/v0.14.4...v0.14.5)

Updates `tonic-prost` from 0.14.4 to 0.14.5
- [Release notes](https://github.com/hyperium/tonic/releases)
- [Changelog](https://github.com/hyperium/tonic/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/tonic/compare/v0.14.4...v0.14.5)

Updates `tonic-reflection` from 0.14.4 to 0.14.5
- [Release notes](https://github.com/hyperium/tonic/releases)
- [Changelog](https://github.com/hyperium/tonic/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/tonic/compare/v0.14.4...v0.14.5)

Updates `tonic-prost-build` from 0.14.4 to 0.14.5
- [Release notes](https://github.com/hyperium/tonic/releases)
- [Changelog](https://github.com/hyperium/tonic/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/tonic/compare/v0.14.4...v0.14.5)

---
updated-dependencies:
- dependency-name: tonic
  dependency-version: 0.14.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: grpc
- dependency-name: tonic-prost
  dependency-version: 0.14.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: grpc
- dependency-name: tonic-reflection
  dependency-version: 0.14.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: grpc
- dependency-name: tonic-prost-build
  dependency-version: 0.14.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: grpc
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-07 02:00:39 +01:00
dependabot[bot]
1efdee7a72
chore(deps): bump tokio from 1.49.0 to 1.50.0 (#63)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.49.0 to 1.50.0.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.49.0...tokio-1.50.0)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-07 02:00:21 +01:00
dependabot[bot]
91ca259f71
chore(deps): bump futures-util from 0.3.31 to 0.3.32 (#55)
Bumps [futures-util](https://github.com/rust-lang/futures-rs) from 0.3.31 to 0.3.32.
- [Release notes](https://github.com/rust-lang/futures-rs/releases)
- [Changelog](https://github.com/rust-lang/futures-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/futures-rs/compare/0.3.31...0.3.32)

---
updated-dependencies:
- dependency-name: futures-util
  dependency-version: 0.3.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-01 17:34:59 +01:00
dependabot[bot]
50d49902ad
chore(deps): bump the grpc group with 4 updates (#54)
Bumps the grpc group with 4 updates: [tonic](https://github.com/hyperium/tonic), [tonic-prost](https://github.com/hyperium/tonic), [tonic-reflection](https://github.com/hyperium/tonic) and [tonic-prost-build](https://github.com/hyperium/tonic).


Updates `tonic` from 0.14.3 to 0.14.4
- [Release notes](https://github.com/hyperium/tonic/releases)
- [Changelog](https://github.com/hyperium/tonic/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/tonic/compare/v0.14.3...v0.14.4)

Updates `tonic-prost` from 0.14.3 to 0.14.4
- [Release notes](https://github.com/hyperium/tonic/releases)
- [Changelog](https://github.com/hyperium/tonic/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/tonic/compare/v0.14.3...v0.14.4)

Updates `tonic-reflection` from 0.14.3 to 0.14.4
- [Release notes](https://github.com/hyperium/tonic/releases)
- [Changelog](https://github.com/hyperium/tonic/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/tonic/compare/v0.14.3...v0.14.4)

Updates `tonic-prost-build` from 0.14.3 to 0.14.4
- [Release notes](https://github.com/hyperium/tonic/releases)
- [Changelog](https://github.com/hyperium/tonic/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/tonic/compare/v0.14.3...v0.14.4)

---
updated-dependencies:
- dependency-name: tonic
  dependency-version: 0.14.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: grpc
- dependency-name: tonic-prost
  dependency-version: 0.14.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: grpc
- dependency-name: tonic-reflection
  dependency-version: 0.14.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: grpc
- dependency-name: tonic-prost-build
  dependency-version: 0.14.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: grpc
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-01 17:34:40 +01:00
dependabot[bot]
5d2ae58682
chore(deps): bump actions/checkout from 4 to 6 (#57)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-01 17:34:14 +01:00
dependabot[bot]
7b5390c2b5
chore(deps): bump clap from 4.5.57 to 4.5.60 (#58)
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.57 to 4.5.60.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.57...clap_complete-v4.5.60)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.60
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-01 17:33:58 +01:00
dependabot[bot]
d79a7a5609
chore(deps): bump tempfile from 3.24.0 to 3.26.0 (#60)
Bumps [tempfile](https://github.com/Stebalien/tempfile) from 3.24.0 to 3.26.0.
- [Changelog](https://github.com/Stebalien/tempfile/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stebalien/tempfile/compare/v3.24.0...v3.26.0)

---
updated-dependencies:
- dependency-name: tempfile
  dependency-version: 3.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-01 17:33:33 +01:00
JasterV
eb3fc20a33 fix: support for cargo binstall 2026-02-11 12:31:16 +01:00
JasterV
d6bffd82dc fix release-binaries.yml workflow 2026-02-11 12:23:28 +01:00
JasterV
b32827a903 chore: add support for cargo binstall 2026-02-11 12:21:05 +01:00
JasterV
322ba2a355 add workflow to publish binaries 2026-02-11 11:45:37 +01:00
github-actions[bot]
4494cc7596
chore: release (#50)
* chore: release

* update

* remove the experimental warning

* update ci workflow

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: JasterV <49537445+JasterV@users.noreply.github.com>
2026-02-06 13:34:57 +01:00
github-actions[bot]
a8b012d6bc
chore: release (#47)
* update changelog & versions

* update Cargo.lock

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: JasterV <49537445+JasterV@users.noreply.github.com>
2026-02-06 13:24:22 +01:00
Víctor Martínez
c9ef611e07
[feat] Generate markdown documentation for gRPC services (#46)
This PR implements a new subcommand `doc` that generates markdown documentation for a given gRPC service!

**Description**

For the most part, the inner logic of this subcommand is the same as the `describe`, the only thing that changes is the way that the found descriptor is transformed to a final output.

In this case, a `Packages` type has been implemented to transform a `ServiceDescriptor` into a map of `Package`s.

Each package groups all the file descriptors with the same package name (or namespace).

A `Package` contains all the necessary information for a file of documentation to be generated (All its contained services, messages and enum descriptors and its name).

The output of this command is a folder with all the generated documentation, which contains a file per protobuf package.

**Introduced the `granc-test-support` crate**

* Renamed the `echo_service` crate as `granc-test-support`, providing both the definition of a protobuf service for integration testing and a function to compile protobuffer at runtime into a file descriptor (Potentially this could be used to let users pass a folder to a proto project in addition to the server reflection and the local file descriptor options. For example, the `call` command could compile a file descriptor on the fly from a folder containing a protobuffer project before making the call to the gRPC server.

**Descriptor API Enhancements:**

* Added `name`, `full_name`, and `package_name` methods to the `Descriptor` enum to simplify access to descriptor metadata. (`granc-core/src/client/types.rs`)

**Dependency Management Improvements:**

* Added grouping for gRPC-related dependencies in `dependabot.yml` for improved automated dependency updates. (`.github/dependabot.yml`)
2026-02-06 13:16:19 +01:00
dependabot[bot]
bc5a13cc79
chore(deps): bump tonic from 0.14.2 to 0.14.3 (#43)
Bumps [tonic](https://github.com/hyperium/tonic) from 0.14.2 to 0.14.3.
- [Release notes](https://github.com/hyperium/tonic/releases)
- [Changelog](https://github.com/hyperium/tonic/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/tonic/compare/v0.14.2...v0.14.3)

---
updated-dependencies:
- dependency-name: tonic
  dependency-version: 0.14.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 09:49:41 +01:00
dependabot[bot]
913baa6c1e
chore(deps): bump tonic-prost from 0.14.2 to 0.14.3 (#40)
Bumps [tonic-prost](https://github.com/hyperium/tonic) from 0.14.2 to 0.14.3.
- [Release notes](https://github.com/hyperium/tonic/releases)
- [Changelog](https://github.com/hyperium/tonic/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/tonic/compare/v0.14.2...v0.14.3)

---
updated-dependencies:
- dependency-name: tonic-prost
  dependency-version: 0.14.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 09:49:19 +01:00
dependabot[bot]
dd52cf47d8
chore(deps): bump clap from 4.5.55 to 4.5.56 (#45)
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.55 to 4.5.56.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.55...clap_complete-v4.5.56)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.56
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 09:46:59 +01:00
dependabot[bot]
cda75f8124
chore(deps): bump tonic-prost-build from 0.14.2 to 0.14.3 (#41)
Bumps [tonic-prost-build](https://github.com/hyperium/tonic) from 0.14.2 to 0.14.3.
- [Release notes](https://github.com/hyperium/tonic/releases)
- [Changelog](https://github.com/hyperium/tonic/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/tonic/compare/v0.14.2...v0.14.3)

---
updated-dependencies:
- dependency-name: tonic-prost-build
  dependency-version: 0.14.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 09:46:09 +01:00
dependabot[bot]
011904c2e5
chore(deps): bump tonic-reflection from 0.14.2 to 0.14.3 (#42)
Bumps [tonic-reflection](https://github.com/hyperium/tonic) from 0.14.2 to 0.14.3.
- [Release notes](https://github.com/hyperium/tonic/releases)
- [Changelog](https://github.com/hyperium/tonic/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/tonic/compare/v0.14.2...v0.14.3)

---
updated-dependencies:
- dependency-name: tonic-reflection
  dependency-version: 0.14.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 09:44:54 +01:00
github-actions[bot]
c05b4fe865 chore: release (#38)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-28 14:39:26 +01:00
github-actions[bot]
687654c46f
chore: release (#37)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-28 14:36:12 +01:00
dependabot[bot]
80ce6d7668
chore(deps): bump clap from 4.5.54 to 4.5.55 (#36)
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.54 to 4.5.55.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.54...clap_complete-v4.5.55)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.55
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-28 14:10:02 +01:00
Víctor Martínez
9990e94c8c
[fix] A URL should not be required for list and describe commands (#35)
solves #34
2026-01-28 14:09:41 +01:00
github-actions[bot]
8ce153e271
chore(granc): update Cargo.lock (#33)
* chore(granc): release v0.6.0

* Update CHANGELOG.md

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Víctor Martínez <49537445+JasterV@users.noreply.github.com>
2026-01-27 15:32:50 +01:00
github-actions[bot]
69336d850e
chore: release (#32)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-27 15:16:36 +01:00
github-actions[bot]
57381ca520
chore: release (#31)
---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: JasterV <49537445+JasterV@users.noreply.github.com>
2026-01-27 15:09:57 +01:00
Víctor Martínez
d9001fc87e
[feature] + [refactor] => Allow ALL commands to be executed against a local file descriptor (#28)
This PR makes the `--file-descriptor-set` CLI option to be global for all the commands.
By consequence, the `GrancClient` has been refactored to use a typestate pattern to ensure that there are two separate and decoupled implementations of the behaviour of the client when a file descriptor is loaded and when server reflection is enabled, since both cases have by nature separate error cases and return values invariants.

It also significantly improves the documentation for both the main `README.md` and the `granc-core/README.md`, clarifying usage patterns, command-line options, and the internal architecture of the `GrancClient` API. 

**API and architecture changes:**

* Refactored `granc-core/src/client.rs` to implement the typestate pattern for `GrancClient`, splitting logic into `with_server_reflection` and `with_file_descriptor` modules. Updated documentation comments to explain state transitions and usage.
* Simplified the `DynamicRequest` struct by removing the `file_descriptor_set` field, as schema resolution is now determined by the client's state rather than per-request.

**Documentation improvements:**

* Expanded and reorganized the main `README.md` to clearly explain new and existing features, including local introspection, command-line options, and usage examples for both server reflection and local file descriptor sets. The documentation now covers how to use introspection commands with and without server reflection, and provides concrete example commands and expected output. [[1]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R16-R33) [[2]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5L60-R73) [[3]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5L84-R159)
* Updated the `granc-core/README.md` to document the typestate pattern of `GrancClient`, provide clear async/sync usage examples for both reflection and file descriptor modes, and clarify schema introspection methods.
2026-01-27 13:08:21 +01:00
github-actions[bot]
d74d8a6bf2
chore: release (#27)
* chore(granc): release v0.5.0

* Update CHANGELOG.md

* update granc dep

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Víctor Martínez <49537445+JasterV@users.noreply.github.com>
2026-01-27 11:38:45 +01:00
Víctor Martínez
17e6fe57a0
refactor: we can use tonic-reflection instead of having to generate the reflection client ourselfs (#29)
This pull request removes the custom-generated gRPC reflection protocol code from the repository and switches to using the `tonic-reflection` crate’s built-in protocol definitions. It also cleans up related tooling and dependencies that are no longer needed.

### Migration to `tonic-reflection` for gRPC reflection

* Replaced internal generated protocol types in `granc-core/src/reflection/client.rs` with imports from `tonic_reflection::pb::v1`, removing the need for the local `generated` module. [[1]](diffhunk://#diff-13deee04dd97de938cc46f0ef4faca083f3b471800e94cf45937122b83f01d57L19-L23) [[2]](diffhunk://#diff-13deee04dd97de938cc46f0ef4faca083f3b471800e94cf45937122b83f01d57R29-R33)
* Deleted the `granc-core/src/reflection/generated.rs` module, which previously contained the generated Rust code for the reflection protocol.
* Removed the reflection proto file (`granc-tools/proto/reflection.proto`) and the `granc-tools` crate, including its build tooling and dependencies, as they are no longer needed. [[1]](diffhunk://#diff-152ff715d002656dc972a294d86490c4857848392f54c73ac7e8818191ca617dL1-L149) [[2]](diffhunk://#diff-8a8fd674fd23e14d5c7a1ab242678a860560b0eee27cd248254510a3d585cbb4L1-L15) [[3]](diffhunk://#diff-2e9d962a08321605940b5a657135052fbcef87b5e360662bb527c96d9a615542L2-R2) [[4]](diffhunk://#diff-9375fd04332c86472d7be397ef09428cb86babd8826880a5835bd1d1c1bdbc08L43-L53)

### Dependency and configuration cleanup

* Updated `granc-core/Cargo.toml` to add `tonic-reflection` as a regular dependency (not just a dev-dependency) and removed the now-unnecessary dev-dependency.

### Codebase simplification

* Removed the now-unused `mod generated;` declaration from `granc-core/src/reflection.rs`.
2026-01-27 02:26:10 +01:00
github-actions[bot]
772b3a45b9 chore: release (#25)
* chore: release

* chore: update versions and dependencies

* update CHANGELOG

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: JasterV <49537445+JasterV@users.noreply.github.com>
2026-01-24 19:58:21 +01:00
Víctor Martínez
26e46a4003
feat: implement list and describe commands (#26)
This pull request introduces major improvements to the `granc` gRPC CLI, focusing on enhanced introspection and discovery features, a more user-friendly command-line interface, and improved error and output formatting. The changes include new commands for listing and describing services, methods, and messages, a restructured CLI argument parser, and a new formatter for colored, readable output. Additionally, the core client is extended to support these new features, and error handling is refactored for clarity.

**New CLI features and UX improvements:**

* Added new `list` and `describe` commands to the CLI, allowing users to discover available services and inspect service/message definitions directly from the server using reflection. The CLI argument structure is now subcommand-based for better usability. [[1]](diffhunk://#diff-dfa67e7f5e147119fe8d665da6b31b3605f5e196734ec7407aab2bcc9e2f656cL8-R84) [[2]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5L71-R139)
* Updated the README with documentation for the new commands and improved usage instructions. [[1]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R21) [[2]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5L71-R139)

**Core client and reflection enhancements:**

* Implemented new methods in the core client for listing services and fetching symbol descriptors via reflection, including robust error types for each operation. [[1]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adL25-R27) [[2]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adL38-R78) [[3]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adR149-R211) [[4]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adL125-R229) [[5]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adL154-R252) [[6]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adL164-R262) [[7]](diffhunk://#diff-13deee04dd97de938cc46f0ef4faca083f3b471800e94cf45937122b83f01d57R19) [[8]](diffhunk://#diff-13deee04dd97de938cc46f0ef4faca083f3b471800e94cf45937122b83f01d57R124-R161)

**Output formatting and error handling:**

* Added a new `formatter` module for producing colored, human-friendly output for all major CLI operations, including pretty-printing of service lists, descriptors, and errors.
* Improved error handling throughout the client and CLI, with more specific error types and user-facing messages. [[1]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adL38-R78) [[2]](diffhunk://#diff-0de6f761cf394791a15b0707e1a41f54559b5626f7aedb06ef339bc1a7ca6287R1-R248)

**Dependency and project structure updates:**

* Updated dependencies and added the `colored` crate for output styling.
2026-01-24 19:39:59 +01:00
JasterV
191120c1d4 fix: bugs and release granc 0.4.0 2026-01-22 18:40:21 +01:00
github-actions[bot]
efe41e1155
chore: release v0.3.1 (#22)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-22 15:49:31 +01:00
JasterV
81ac1d4be1 chore: update granc-core documentation 2026-01-22 15:47:24 +01:00
github-actions[bot]
21b0b524d2
chore: release v0.3.0 (#21)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-22 15:45:42 +01:00
Víctor Martínez
8452a6786b
refactor: separate reflection generation (#20) 2026-01-22 15:42:33 +01:00
github-actions[bot]
f75dc1b9a4 chore: release v0.2.4 (#17)
* chore: release v0.2.4

* chore: update CHANGELOG and README

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: JasterV <49537445+JasterV@users.noreply.github.com>
2026-01-22 15:24:02 +01:00
Víctor Martínez
7bc2e4c0a9
refactor: separate core logic into a library crate granc-core (#16)
This pull request introduces a significant internal refactor of the `granc` project, decoupling core dynamic gRPC client logic into a new reusable library crate (`granc-core`). It also improves project organization, updates documentation, and enhances workspace configuration. The main CLI functionality is now built atop this new core, making future maintenance and extensibility easier.

**Project structure and workspace improvements:**

- Created a new crate, `granc-core`, to encapsulate all core dynamic gRPC client logic, including schema resolution, dynamic request dispatch, and reflection support. This enables potential reuse outside the CLI and clarifies project boundaries. (`granc-core/Cargo.toml`, `granc-core/src/client.rs`, [[1]](diffhunk://#diff-dd6f7ed591a1bd2577444d0079c1f56851ef74e3b9df75a86ef4af76681435f6R1-R126) [[2]](diffhunk://#diff-ddab7585cf4c860c9922ed56471bccf5804da60f0ccb174158fd31b9b82457abR1-R46) [[3]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adR1-R186)
- Updated workspace configuration in `Cargo.toml` to include `granc-core`, centralize dependency versions, and set workspace-wide package metadata for consistency. (`Cargo.toml`, [Cargo.tomlL2-R26](diffhunk://#diff-2e9d962a08321605940b5a657135052fbcef87b5e360662bb527c96d9a615542L2-R26))
- Adjusted `echo-service` and other crates to use workspace-wide settings for edition and authors. (`echo-service/Cargo.toml`, [echo-service/Cargo.tomlL3-R3](diffhunk://#diff-e74eb8a3bebf341a9bee1cdcd5cd3a50e15998db5a9df9eaf9e7aec341287b1eL3-R3))

**Documentation :**

- Added a detailed `README.md` for both the main project and the new `granc-core` library, providing clear installation, usage, and architecture guidance for users and contributors. (`README.md`, `granc-core/README.md`, [[1]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R1-R172) [[2]](diffhunk://#diff-dd6f7ed591a1bd2577444d0079c1f56851ef74e3b9df75a86ef4af76681435f6R1-R126)

**Build and tooling updates:**

- Updated `Makefile.toml` to use workspace-wide test runs and renamed tasks/binaries for consistency with the new crate layout. (`Makefile.toml`, [[1]](diffhunk://#diff-9375fd04332c86472d7be397ef09428cb86babd8826880a5835bd1d1c1bdbc08L18-R18) [[2]](diffhunk://#diff-9375fd04332c86472d7be397ef09428cb86babd8826880a5835bd1d1c1bdbc08L45-R50)

---

**Key changes:**

**1. Core library extraction and refactor**
- Moved dynamic gRPC client logic (including `GrancClient`, request/response types, and reflection handling) into a new `granc-core` crate, decoupling it from the CLI and preparing for independent publishing. [[1]](diffhunk://#diff-ddab7585cf4c860c9922ed56471bccf5804da60f0ccb174158fd31b9b82457abR1-R46) [[2]](diffhunk://#diff-46d757daaa6737f1a6247142e8abff1cb5079109e641c447e8a9793ea1f063adR1-R186)

**2. Workspace and dependency management**
- Updated the root `Cargo.toml` to add `granc-core` as a workspace member, centralize dependency versions, and set workspace-wide metadata fields (authors, edition, license, etc.).
- Adjusted `echo-service` and new crates to inherit workspace settings for consistency.

**3. Documentation**
- Updated and added comprehensive `README.md` files for both the main project and the new core library, with installation, usage, and architecture sections. [[1]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R1-R172) [[2]](diffhunk://#diff-dd6f7ed591a1bd2577444d0079c1f56851ef74e3b9df75a86ef4af76681435f6R1-R126)
- Introduced a `CHANGELOG.md` to document project history and recent changes.

**4. Build and CI tooling**
- Updated test and generation commands in `Makefile.toml` to reflect the new workspace structure and binary names. [[1]](diffhunk://#diff-9375fd04332c86472d7be397ef09428cb86babd8826880a5835bd1d1c1bdbc08L18-R18) [[2]](diffhunk://#diff-9375fd04332c86472d7be397ef09428cb86babd8826880a5835bd1d1c1bdbc08L45-R50)

**5. Housekeeping**
- Removed outdated or redundant files as part of the refactor. [[1]](diffhunk://#diff-06572a96a58dc510037d5efa622f9bec8519bc1beab13c9f251e97e657a9d4edL1) [[2]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5L1)

This refactor lays the groundwork for improved maintainability, easier future development, and potential wider adoption of the dynamic gRPC client logic outside the CLI.
2026-01-22 15:07:10 +01:00
github-actions[bot]
09478c6b19
chore: release v0.2.3 (#14)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-21 15:58:10 +01:00
JasterV
27674a03ab refactor: decouple ReflectionClient to possibly publish in a separate crate 2026-01-21 15:50:22 +01:00
github-actions[bot]
5f6ffaa3ab chore: release v0.2.2 (#13)
Co-authored-by: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
2026-01-21 15:50:22 +01:00
JasterV
949dad63ff chore: update README.md 2026-01-21 13:57:02 +01:00
github-actions[bot]
cd63af7436
chore: release v0.2.1 (#12)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-21 13:52:24 +01:00
JasterV
eaf99b1034 chore: update README 2026-01-21 13:50:59 +01:00
github-actions[bot]
e5b1296ab5
chore: release v0.2.0 (#10)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-21 13:33:23 +01:00
Víctor Martínez
8cc7003344
refactor: implement automatic reflection (#9)
This pull request introduces dynamic server reflection support to the `granc` CLI, allowing users to call gRPC services without needing a local descriptor file. The changes include new reflection client logic, CLI and core refactoring, an updated README, and build system improvements.

**Server Reflection Support:**

* Added a copy of the official `grpc.reflection.v1` proto file to `granc/proto/reflection.proto` to enable dynamic schema fetching from servers that support reflection.
* Implemented logic in the core orchestration layer (`granc/src/core.rs`) to resolve service descriptors either from a local file or dynamically via server reflection. The CLI now works seamlessly whether or not a `--proto-set` is provided.

**CLI and Core Refactoring:**

* Moved the CLI definition to `granc/src/cli.rs`, now supporting an optional `--proto-set`, improved endpoint parsing, and conversion to the new internal core `Input` type.
* Refactored the core logic into `granc/src/core.rs` and implemented a more structured input, output and error handling, now making the core logic unit testable.

**Documentation and Usability:**

* Updated `granc/README.md` to document the new server reflection feature, clarify usage with and without descriptor files, and revise CLI argument documentation and examples. [[1]](diffhunk://#diff-0648f0ef1e166ae07f3ab14aa268c3497c2e3a49ffa189f85f9dfb88493f2440R17-L19) [[2]](diffhunk://#diff-0648f0ef1e166ae07f3ab14aa268c3497c2e3a49ffa189f85f9dfb88493f2440L34-R44) [[3]](diffhunk://#diff-0648f0ef1e166ae07f3ab14aa268c3497c2e3a49ffa189f85f9dfb88493f2440L57-R101) [[4]](diffhunk://#diff-0648f0ef1e166ae07f3ab14aa268c3497c2e3a49ffa189f85f9dfb88493f2440L97) [[5]](diffhunk://#diff-0648f0ef1e166ae07f3ab14aa268c3497c2e3a49ffa189f85f9dfb88493f2440L106-R125)

**Generating a reflection service:**

* Updated `granc/Cargo.toml` to add a `gen-proto` feature, declare both binaries (`granc` and `generate_reflection_service`), and manage dependencies for reflection and proto generation.
* Improved `Makefile.toml` with new tasks for generating the reflection service client.
2026-01-21 13:29:17 +01:00
JasterV
81b4d1ac1c chore: update README 2026-01-20 18:32:53 +01:00
56 changed files with 4448 additions and 1059 deletions

View file

@ -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"

View file

@ -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 }}

View file

@ -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
View 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
View 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

View 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

View file

@ -1 +0,0 @@
granc/CHANGELOG.md

162
CHANGELOG.md Normal file
View 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
View file

@ -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"

View file

@ -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"

View file

@ -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"]

View file

@ -1 +0,0 @@
granc/README.md

295
README.md Normal file
View file

@ -0,0 +1,295 @@
# Granc 🦀
[![granc on crates.io](https://img.shields.io/crates/v/granc)](https://crates.io/crates/granc)
[![License](https://img.shields.io/crates/l/granc.svg)](https://github.com/JasterV/granc/blob/main/LICENSE)
**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.

View file

@ -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
View 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)

View 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
View 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)
---

View 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)
---

View 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;
}

View 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;
}

View 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
View 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
View file

@ -0,0 +1,124 @@
# Granc Core
[![Crates.io](https://img.shields.io/crates/v/granc_core.svg)](https://crates.io/crates/granc_core)
[![Documentation](https://docs.rs/granc_core/badge.svg)](https://docs.rs/granc_core)
[![License](https://img.shields.io/crates/l/granc_core.svg)](https://github.com/JasterV/granc/blob/main/LICENSE)
**`granc-core`** is the foundational library powering the [Granc CLI](https://crates.io/crates/granc). It provides a dynamic gRPC client capability that allows you to interact with *any* gRPC server without needing compile-time Protobuf code generation.
Instead of strictly typed Rust structs, this library bridges standard `serde_json::Value` payloads directly to Protobuf binary wire format at runtime.
## 🚀 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
View 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
}
}

View 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
}
}

View 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?)
}
}

View 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()),
}
}

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

View 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)
}

View file

@ -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
View 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>;

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

View 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)
}

View file

@ -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]

View 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());
}

View 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
));
}

View 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")
));
}

View 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"),
}
}

View 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 }

View 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")
}

View file

@ -0,0 +1,2 @@
pub mod compiler;
pub mod echo_service;

View file

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

View file

@ -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" }

View file

@ -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
View 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);
}
}

View file

@ -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)
}

View file

@ -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");
}

View file

@ -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
View file

@ -0,0 +1,2 @@
pub mod markdown;
mod package;

View 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
View 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
View 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)
}
}

View file

@ -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);
}
}
}
}

View file

@ -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-"