commit a533c4aa83622b85e8a7ed9491ac474a74bff327 Author: Víctor Martínez <49537445+JasterV@users.noreply.github.com> Date: Mon Jan 19 23:25:18 2026 +0100 feat: initial release diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..45b791a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + time: "09:00" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..1a231e7 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,53 @@ +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 }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3999ed4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +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') }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 120000 index 0000000..466ecab --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +grab/CHANGELOG.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b83e045 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1271 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "echo-service" +version = "0.0.0" +dependencies = [ + "bytes", + "prost", + "prost-types", + "tonic", + "tonic-prost", + "tonic-prost-build", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "grab" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "echo-service", + "futures-util", + "http", + "http-body", + "prost", + "prost-reflect", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tonic", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +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" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-reflect" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89455ef41ed200cafc47c76c552ee7792370ac420497e551f16123a9135f76e" +dependencies = [ + "base64", + "prost", + "prost-types", + "serde", + "serde-value", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c40aaccc9f9eccf2cd82ebc111adc13030d23e887244bc9cfa5d1d636049de3" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tonic-prost" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a16cba4043dc3ff43fcb3f96b4c5c154c64cbd18ca8dce2ab2c6a451d058a2" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +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" + +[[package]] +name = "zmij" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1420f64 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["grab", "echo-service"] +resolver = "2" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile.toml b/Makefile.toml new file mode 100644 index 0000000..3299e44 --- /dev/null +++ b/Makefile.toml @@ -0,0 +1,51 @@ +[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 grab' to explicitly target the CLI binary in the workspace +args = ["run", "-p", "grab", "${@}"] + +[tasks.fmt] +description = "Formats all source files" +command = "cargo" +args = ["fmt", "--all"] + +[tasks.fmt-check] +description = "Checks formatting without modifying files (fails if unformatted)" +command = "cargo" +args = ["fmt", "--all", "--", "--check"] + +[tasks.clippy] +description = "Runs Clippy lints on the workspace" +command = "cargo" +# Added '--workspace' to lint both crates +args = [ + "clippy", + "--workspace", + "--all-targets", + "--all-features", + "--", + "-D", + "warnings", +] + +[tasks.test] +description = "Runs tests for the grab crate only" +command = "cargo" +# Added '-p grab' to strictly run integration/unit tests for the CLI +args = ["nextest", "run", "--no-fail-fast", "-p", "grab"] diff --git a/README.md b/README.md new file mode 120000 index 0000000..6e07dad --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +grab/README.md \ No newline at end of file diff --git a/echo-service/Cargo.toml b/echo-service/Cargo.toml new file mode 100644 index 0000000..3b53e4c --- /dev/null +++ b/echo-service/Cargo.toml @@ -0,0 +1,14 @@ +[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" diff --git a/echo-service/build.rs b/echo-service/build.rs new file mode 100644 index 0000000..350c0a6 --- /dev/null +++ b/echo-service/build.rs @@ -0,0 +1,24 @@ +use std::env::var; +use std::io::Result; + +fn main() -> Result<()> { + // List of proto files containing a message definition + let proto_files = &[ + // Services + "proto/echo.proto", + ]; + + // Name of the folder containing the proto definitions + let proto_folder = "proto"; + let out_dir = var("OUT_DIR").expect("Missing OUT_DIR environment variable"); + let descriptors_path = format!("{}/descriptors.bin", out_dir); + + tonic_prost_build::configure() + .file_descriptor_set_path(descriptors_path) + .protoc_arg("--experimental_allow_proto3_optional") + .build_client(false) + .compile_protos(proto_files, &[proto_folder]) + .unwrap(); + + Ok(()) +} diff --git a/echo-service/proto/echo.proto b/echo-service/proto/echo.proto new file mode 100644 index 0000000..148b270 --- /dev/null +++ b/echo-service/proto/echo.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package echo; + +service EchoService { + rpc UnaryEcho (EchoRequest) returns (EchoResponse); + rpc ServerStreamingEcho (EchoRequest) returns (stream EchoResponse); + rpc ClientStreamingEcho (stream EchoRequest) returns (EchoResponse); + rpc BidirectionalEcho (stream EchoRequest) returns (stream EchoResponse); +} + +message EchoRequest { + string message = 1; +} + +message EchoResponse { + string message = 1; +} diff --git a/echo-service/src/lib.rs b/echo-service/src/lib.rs new file mode 100644 index 0000000..292ae14 --- /dev/null +++ b/echo-service/src/lib.rs @@ -0,0 +1,12 @@ +//! # Echo Service +//! +//! **INTERNAL USE ONLY**: This crate exists solely to provide a gRPC server implementation +//! and descriptor set for integration testing the `grab` CLI tool. +//! It is not intended for production use. + +pub mod pb { + include!(concat!(env!("OUT_DIR"), "/echo.rs")); +} + +pub use pb::echo_service_server::{EchoService, EchoServiceServer}; +pub const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("descriptors"); diff --git a/grab/CHANGELOG.md b/grab/CHANGELOG.md new file mode 100644 index 0000000..c6be0c2 --- /dev/null +++ b/grab/CHANGELOG.md @@ -0,0 +1,22 @@ +# 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] + +## `grab` - [0.1.0](https://github.com/JasterV/grab/compare/grab-v0.1.0...grab-v0.1.1) - 2026-01-20 + +### Added +- **Dynamic gRPC Client**: Implemented a CLI that performs gRPC calls without generating Rust code, bridging JSON payloads to Protobuf binary format at runtime. +- **Schema Loading**: Support for loading Protobuf schemas dynamically from binary `FileDescriptorSet` (`.bin` or `.pb`) files. +- **Full Streaming Support**: Automatic dispatch for all four gRPC access patterns based on the method descriptor: + - Unary (Single Request → Single Response) + - Server Streaming (Single Request → Stream) + - Client Streaming (Stream → Single Response) + - Bidirectional Streaming (Stream → Stream) +- **JSON Transcoding**: Custom `tonic::Codec` implementation (`JsonCodec`) to validate and transcode `serde_json::Value` to/from Protobuf bytes on the fly. +- **Metadata Support**: Ability to attach custom headers/metadata to requests via the `-H` / `--header` flag. +- **Input Validation**: Fast-fail validation that checks if the provided JSON structure is valid before making the network request. diff --git a/grab/Cargo.toml b/grab/Cargo.toml new file mode 100644 index 0000000..b0c43ce --- /dev/null +++ b/grab/Cargo.toml @@ -0,0 +1,32 @@ +[package] +authors = ["Victor Martínez Montané "] +categories = ["network-programming", "command-line-utilities"] +description = "A dynamic gRPC CLI tool written in Rust (gRPC + Crab)" +edition = "2024" +homepage = "https://github.com/JasterV/grab" +keywords = ["cli", "command-line", "grpc", "grpcurl", "curl"] +license = "MIT OR Apache-2.0" +name = "grab" +publish = true +readme = "README.md" +repository = "https://github.com/JasterV/grab" +rust-version = "1.89" +version = "0.1.0" + +[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" + +[dev-dependencies] +echo-service = { path = "../echo-service" } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] } diff --git a/grab/README.md b/grab/README.md new file mode 100644 index 0000000..1c6292f --- /dev/null +++ b/grab/README.md @@ -0,0 +1,146 @@ +# gRab 🦀 + +> ⚠️ **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. + +**gRab** (gRPC + Crab) 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, gRab 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/grab +cd grab +cargo install --path . +``` + +## 🛠️ Prerequisites: Generating Descriptors + +To use gRab, 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 +grab [OPTIONS] + +``` + +### Arguments + +| Argument | Description | Required | +| --- | --- | --- | +| `` | Server address (e.g., `http://[::1]:50051`). | **Yes** | +| `` | Fully qualified method name (e.g., `my.package.Service/Method`). | **Yes** | + +### Options + +| Flag | Short | Description | Required | +| --- | --- | --- | --- | +| `--proto-set` | | Path to the binary FileDescriptorSet (`.bin`). | **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 +grab \ + --proto-set ./descriptor.bin \ + --body '{"name": "Ferris"}' \ + http://localhost:50051 \ + helloworld.Greeter/SayHello +``` + +**2. Bidirectional Streaming (Chat)** + +```bash +grab \ + --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. diff --git a/grab/src/client.rs b/grab/src/client.rs new file mode 100644 index 0000000..01a4b98 --- /dev/null +++ b/grab/src/client.rs @@ -0,0 +1,193 @@ +//! # 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, 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 { + 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, 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>, 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 + Send + 'static, + headers: Vec<(String, String)>, + ) -> Result, 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 + Send + 'static, + headers: Vec<(String, String)>, + ) -> Result< + Result>, 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(payload: T, headers: Vec<(String, String)>) -> Result, 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) +} diff --git a/grab/src/client/integration_test.rs b/grab/src/client/integration_test.rs new file mode 100644 index 0000000..38ca3f4 --- /dev/null +++ b/grab/src/client/integration_test.rs @@ -0,0 +1,133 @@ +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"); +} diff --git a/grab/src/client/integration_test/echo_service_impl.rs b/grab/src/client/integration_test/echo_service_impl.rs new file mode 100644 index 0000000..128d61c --- /dev/null +++ b/grab/src/client/integration_test/echo_service_impl.rs @@ -0,0 +1,88 @@ +use echo_service::EchoService; +use echo_service::pb::{EchoRequest, EchoResponse}; + +use futures_util::Stream; +use std::pin::Pin; +use tokio::sync::mpsc; +use tokio_stream::{StreamExt, wrappers::ReceiverStream}; +use tonic::{Request, Response, Status, Streaming}; + +pub struct EchoServiceImpl; + +#[tonic::async_trait] +impl EchoService for EchoServiceImpl { + type BidirectionalEchoStream = Pin> + Send>>; + type ServerStreamingEchoStream = ReceiverStream>; + + async fn unary_echo( + &self, + request: Request, + ) -> Result, Status> { + Ok(Response::new(EchoResponse { + message: request.into_inner().message, + })) + } + + async fn server_streaming_echo( + &self, + request: Request, + ) -> Result, Status> { + let msg = request.into_inner().message; + let (tx, rx) = mpsc::channel(4); + + tokio::spawn(async move { + for i in 0..3 { + let response = EchoResponse { + message: format!("{} - seq {}", msg, i), + }; + tx.send(Ok(response)).await.ok(); + } + }); + + Ok(Response::new(ReceiverStream::new(rx))) + } + + async fn client_streaming_echo( + &self, + request: Request>, + ) -> Result, Status> { + let mut stream = request.into_inner(); + let mut full_msg = String::new(); + + while let Some(req) = stream.next().await { + let req = req?; + full_msg.push_str(&req.message); + } + + Ok(Response::new(EchoResponse { message: full_msg })) + } + + async fn bidirectional_echo( + &self, + request: Request>, + ) -> Result, Status> { + let mut in_stream = request.into_inner(); + let (tx, rx) = mpsc::channel(128); + + tokio::spawn(async move { + while let Some(result) = in_stream.next().await { + match result { + Ok(req) => { + let resp = EchoResponse { + message: format!("echo: {}", req.message), + }; + if tx.send(Ok(resp)).await.is_err() { + break; + } + } + Err(e) => { + let _ = tx.send(Err(e)).await; + break; + } + } + } + }); + + Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) + } +} diff --git a/grab/src/codec.rs b/grab/src/codec.rs new file mode 100644 index 0000000..ed9d3ce --- /dev/null +++ b/grab/src/codec.rs @@ -0,0 +1,98 @@ +//! # JSON <-> Protobuf Codec +//! +//! This module implements a custom `tonic::codec::Codec` that allows `tonic` to work +//! directly with `serde_json::Value`. +//! +//! 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. + +use prost::Message; +use prost_reflect::{DynamicMessage, MessageDescriptor}; +use tonic::{ + Status, + codec::{Codec, DecodeBuf, Decoder, EncodeBuf, Encoder}, +}; + +/// A custom Codec that bridges `serde_json::Value` and Protobuf binary format. +/// +/// It holds the descriptors (schemas) for both the request and the response messages, +/// allowing it to perform dynamic serialization. +pub struct JsonCodec { + /// Schema for the input message. + req_desc: MessageDescriptor, + /// Schema for the output message. + res_desc: MessageDescriptor, +} + +impl JsonCodec { + /// Creates a new `JsonCodec`. + /// + /// # Arguments + /// * `req_desc` - Descriptor for the request message type. + /// * `res_desc` - Descriptor for the response message type. + pub fn new(req_desc: MessageDescriptor, res_desc: MessageDescriptor) -> Self { + Self { req_desc, res_desc } + } +} + +impl Codec for JsonCodec { + type Encode = serde_json::Value; + type Decode = serde_json::Value; + + type Encoder = JsonEncoder; + type Decoder = JsonDecoder; + + fn encoder(&mut self) -> Self::Encoder { + JsonEncoder(self.req_desc.clone()) + } + + fn decoder(&mut self) -> Self::Decoder { + JsonDecoder(self.res_desc.clone()) + } +} + +/// Responsible for encoding a JSON value into Protobuf bytes. +pub struct JsonEncoder(MessageDescriptor); + +impl Encoder for JsonEncoder { + type Item = serde_json::Value; + type Error = Status; + + fn encode(&mut self, item: Self::Item, dst: &mut EncodeBuf<'_>) -> Result<(), Self::Error> { + // DynamicMessage::deserialize accepts any Serde Deserializer. + // serde_json::Value implements IntoDeserializer, so we can pass it directly. + let msg = DynamicMessage::deserialize(self.0.clone(), item).map_err(|e| { + Status::invalid_argument(format!( + "JSON structure does not match Protobuf schema: {}", + e + )) + })?; + + msg.encode_raw(dst); + Ok(()) + } +} + +/// Responsible for decoding Protobuf bytes into a JSON value. +pub struct JsonDecoder(MessageDescriptor); + +impl Decoder for JsonDecoder { + type Item = serde_json::Value; + type Error = Status; + + fn decode(&mut self, src: &mut DecodeBuf<'_>) -> Result, Self::Error> { + // 1. Decode Bytes -> DynamicMessage + let mut msg = DynamicMessage::new(self.0.clone()); + msg.merge(src) + .map_err(|e| Status::internal(format!("Failed to decode Protobuf bytes: {}", e)))?; + + // 2. DynamicMessage -> serde_json::Value + // We convert the DynamicMessage into a Value structure. + // This is efficient and keeps the Client working with structured data. + let value = serde_json::to_value(&msg) + .map_err(|e| Status::internal(format!("Failed to map response to JSON: {}", e)))?; + + Ok(Some(value)) + } +} diff --git a/grab/src/descriptor.rs b/grab/src/descriptor.rs new file mode 100644 index 0000000..27b4cb3 --- /dev/null +++ b/grab/src/descriptor.rs @@ -0,0 +1,68 @@ +//! # 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 { + 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) -> Result { + 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 { + 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())) + } +} diff --git a/grab/src/main.rs b/grab/src/main.rs new file mode 100644 index 0000000..7122a93 --- /dev/null +++ b/grab/src/main.rs @@ -0,0 +1,175 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +/// # gRab 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 std::process; + +mod client; +mod codec; +mod descriptor; + +#[derive(Parser)] +#[command(name = "grab", 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)?; + + let body_json: serde_json::Value = + serde_json::from_str(&args.body).map_err(|e| anyhow::anyhow!("Invalid JSON: {}", e))?; + + let client = GrpcClient::connect(&args.url).await?; + + println!("Calling {}...", args.method); + + 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, + } +} + +// --- Handlers --- + +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(()) +} + +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(()) +} + +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 + 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> + Unpin, +) { + while let Some(result) = stream.next().await { + match result { + Ok(val) => print_json(&val), + Err(status) => print_status(status), + } + } +} diff --git a/release-plz.toml b/release-plz.toml new file mode 100644 index 0000000..8febec2 --- /dev/null +++ b/release-plz.toml @@ -0,0 +1,22 @@ +[workspace] +# set the path of all the crates to the changelog to the root of the repository +changelog_path = "./CHANGELOG.md" +pr_draft = true +pr_labels = ["release"] +pr_branch_prefix = "release-" + +[changelog] +body = """ + +## `{{ package }}` - [{{ version }}]{%- if release_link -%}({{ release_link }}){% endif %} - {{ timestamp | date(format="%Y-%m-%d") }} +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | upper_first }} +{% for commit in commits %} +{%- if commit.scope -%} +- *({{commit.scope}})* {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }}{%- if commit.links %} ({% for link in commit.links %}[{{link.text}}]({{link.href}}) {% endfor -%}){% endif %} +{% else -%} +- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }} +{% endif -%} +{% endfor -%} +{% endfor -%} +""" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..bb18933 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.92" +components = ["rustfmt", "clippy", "rust-src", "rust-analyzer"] diff --git a/yamlfmt.yml b/yamlfmt.yml new file mode 100644 index 0000000..0d7c56d --- /dev/null +++ b/yamlfmt.yml @@ -0,0 +1,3 @@ +formatter: + type: basic + retain_line_breaks_single: true