From 1da70ac5fdce1783d6a9167c05414f8cd91249bb Mon Sep 17 00:00:00 2001 From: Thomas Pellissier-Tanon Date: Mon, 25 Nov 2024 17:50:08 +0100 Subject: [PATCH] Adds the client --- .github/workflows/ci.yml | 8 + Cargo.lock | 1223 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 17 +- client/Cargo.toml | 26 + client/README.md | 74 +++ client/src/lib.rs | 731 +++++++++++++++++++++++ deny.toml | 1 + error/Cargo.toml | 12 + error/README.md | 6 + error/src/lib.rs | 161 +++++ 10 files changed, 2258 insertions(+), 1 deletion(-) create mode 100644 client/Cargo.toml create mode 100644 client/README.md create mode 100644 client/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ef945f..5874b0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,10 @@ jobs: - uses: actions/checkout@v4 - run: rustup component add clippy - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --all-targets -- -D warnings -D clippy::all + working-directory: error + - run: cargo clippy --all-targets -- -D warnings -D clippy::all + working-directory: client - run: cargo clippy --all-targets -- -D warnings -D clippy::all - run: cargo clippy --all-targets --all-features -- -D warnings -D clippy::all @@ -32,6 +36,10 @@ jobs: steps: - uses: actions/checkout@v4 - uses: Swatinem/rust-cache@v2 + - run: cargo test + working-directory: error + - run: cargo test + working-directory: client - run: cargo test - run: cargo test --all-features diff --git a/Cargo.lock b/Cargo.lock index 01ac3d0..5405a81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,1229 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "anyhow" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" + +[[package]] +name = "async-trait" +version = "0.1.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper 1.0.2", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytes" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[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-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-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" + +[[package]] +name = "hyper" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97818827ef4f364230e16705d4706e2897df2bb60617d6ca15d598025a3c481f" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "540654e97a3f4470a492cd30ff187bc95d89557a903a2bbf112e2fae98104ef2" + +[[package]] +name = "js-sys" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.164" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" + +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +dependencies = [ + "hermit-abi", + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.36.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "proc-macro2" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b0487d90e047de87f984913713b85c601c05609aad5b0df4b4573fbf69aa13f" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9552f850d5f0964a4e4d0bf306459ac29323ddfbae05e35a7c0d35cb0803cc5" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-reflect" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b7535b02f0e5efe3e1dbfcb428be152226ed0c66cad9541f2274c8ba8d4cd40" +dependencies = [ + "base64", + "once_cell", + "prost", + "prost-reflect-derive", + "prost-types", + "serde", + "serde-value", +] + +[[package]] +name = "prost-reflect-derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fce6b22f15cc8d8d400a2b98ad29202b33bd56c7d9ddd815bc803a807ecb65" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4759aa0d3a6232fb8dbdb97b61de2c20047c68aca932c7ed76da9d788508d670" +dependencies = [ + "prost", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "reqwest" +version = "0.12.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-registry", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustversion" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "serde" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +dependencies = [ + "serde_derive", +] + +[[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_derive" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "syn" +version = "2.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.41.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" +dependencies = [ + "backtrace", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 0.1.2", + "tower-layer", + "tower-service", +] + +[[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.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", +] + +[[package]] +name = "trait-variant" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twurst-client" +version = "0.0.0" +dependencies = [ + "http", + "http-body", + "http-body-util", + "prost-reflect", + "reqwest", + "serde", + "serde_json", + "tokio", + "tower", + "tower-service", + "trait-variant", + "twurst-error", +] + [[package]] name = "twurst-error" version = "0.0.0" +dependencies = [ + "axum", + "http", + "serde", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[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.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" + +[[package]] +name = "web-sys" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-registry" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +dependencies = [ + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index db78207..2d8c6c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["error"] +members = ["client", "error"] resolver = "2" [workspace.package] @@ -7,3 +7,18 @@ edition = "2021" version = "0.0.0" license = "Apache-2.0" rust-version = "1.75" + +[workspace.dependencies] +axum-07 = { package = "axum", version = "0.7", default-features = false } +http = "1" +http-body = "1" +http-body-util = "0.1" +prost = "0.13" +prost-reflect = "0.14" +reqwest-012 = { package = "reqwest", version = "0.12", default-features = false } +serde = "1.0.132" +serde_json = "1" +tokio = "1" +tower-service = "0.3.1" +tower = "0.5" +trait-variant = "0.1" diff --git a/client/Cargo.toml b/client/Cargo.toml new file mode 100644 index 0000000..a4fb10d --- /dev/null +++ b/client/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "twurst-client" +description = "Twirp client related code" +edition.workspace = true +version.workspace = true +license.workspace = true +rust-version.workspace = true + +[features] +reqwest-012 = ["dep:reqwest-012"] + +[dependencies] +http.workspace = true +http-body.workspace = true +http-body-util.workspace = true +twurst-error = { path = "../error", features = ["http"] } +prost-reflect = { workspace = true, features = ["derive", "serde"] } +reqwest-012 = { workspace = true, optional = true } +serde_json.workspace = true +serde.workspace = true +tower-service.workspace = true +trait-variant.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } +tower = { workspace = true, features = ["util"] } diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..d12ebfc --- /dev/null +++ b/client/README.md @@ -0,0 +1,74 @@ +Crate implementing the needed runtime for the code generated by `twurst-build` +in order to run [Twirp](https://twitchtv.github.io/twirp/docs/spec_v7.html) clients. + +## Getting started + +To start you need first to have a gRPC `.proto` file (e.g. `service.proto`). + +Then build your proto files by creating a `build.rs` file with: +```rust,ignore +fn main() -> std::io::Result<()> { + twurst_build::TwirpBuilder::new() + .with_client() + .compile_protos(&["proto/service.proto"], &["proto"]) +} +``` + +and add to your `Cargo.toml`: +```toml +[dependencies] +prost = "" +prost-types = "" +prost-reflect = "" +twurst-client = { version = "", features=["reqwest-012"] } + +[build-dependencies] +twurst-build = "" +``` + +Note that `protoc` must be available, see [`prost-build` documentation on this topic](https://docs.rs/prost-build/latest/prost_build/#sourcing-protoc). +If you have nix installed, we also provide a dev-shell that provides `protoc`. Use `nix develop` or `direnv` to enter the dev-shell. + +Then you can use the Twirp client with: +```rust,ignore +use twurst_client::TwirpHttpClient; + +mod proto { + include!(concat!(env!("OUT_DIR"), "/example.rs")); // example is the name of your proto package +} + +async fn main() { + let twirp_client = TwirpHttpClient::new_using_reqwest_012("http://example.com/twirp"); + let client = proto::ExampleServiceClient::new(twirp_client); // ExampleServiceClient is the name of the gRPC service + let response = client.test(TestRequest {}).await?; // Does a Twirp request +} +``` + +Note that you can custom the HTTP client with any [`tower`](https://docs.rs/tower) or [`tower-http`](https://docs.rs/tower-http) layer. +For example to add a basic authorization header to all requests: +```rust,ignore +use twurst_client::{TwirpHttpClient, Reqwest012Service}; +use tower::ServiceBuilder; +use tower_http::auth::AddAuthorizationLayer; + +fn main() { + let twirp_client = TwirpHttpClient::new_with_base( + ServiceBuilder::new() + .layer(AddAuthorizationLayer::basic("username", "password")) + .service(Reqwest012Service::from(reqwest::Client::new())) + ); +} +``` + +## Cargo features +- `reqwest-012` allows to use [`reqwest` 0.12](https://docs.rs/reqwest/0.12/) HTTP implementation. + +## License + +Copyright 2024 Helsing GmbH + +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 + +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/client/src/lib.rs b/client/src/lib.rs new file mode 100644 index 0000000..f1aaa0d --- /dev/null +++ b/client/src/lib.rs @@ -0,0 +1,731 @@ +#![doc = include_str!("../README.md")] +#![doc(test(attr(deny(warnings))))] +#![cfg_attr(docsrs, feature(doc_auto_cfg))] + +use http::header::CONTENT_TYPE; +use http::{HeaderValue, Method, Request, Response, StatusCode}; +use http_body::{Body, Frame, SizeHint}; +use http_body_util::BodyExt; +use prost_reflect::bytes::{Buf, Bytes, BytesMut}; +use prost_reflect::{DynamicMessage, ReflectMessage}; +use serde::Serialize; +use std::convert::Infallible; +use std::error::Error; +use std::future::poll_fn; +#[cfg(feature = "reqwest-012")] +use std::future::Future; +use std::mem::take; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tower_service::Service; +pub use twurst_error::{TwirpError, TwirpErrorCode}; + +const APPLICATION_JSON: HeaderValue = HeaderValue::from_static("application/json"); +const APPLICATION_PROTOBUF: HeaderValue = HeaderValue::from_static("application/protobuf"); + +/// Underlying client used by autogenerated clients to handle networking. +/// +/// Can be constructed with [`TwirpHttpClient::new_using_reqwest_012`] to use [`reqwest 0.12`](reqwest_012) +/// or from a regular [`tower::Service`](Service) using [`TwirpHttpClient::new_with_base`] +/// or [`TwirpHttpClient::new`] if relative URLs are fine. +/// +/// URL grammar for twirp service is `URL ::= Base-URL [ Prefix ] "/" [ Package "." ] Service "/" Method`. +/// The `/ [ Package "." ] Service "/" Method` part is auto-generated by the build step +/// but the `Base-URL [ Prefix ]` must be set to do proper call to remote services. +/// This is the `base_url` parameter. +/// If not filled, request URL is only going to be the auto-generated part. +#[derive(Clone)] +pub struct TwirpHttpClient { + service: S, + base_url: Option, + use_json: bool, +} + +#[cfg(feature = "reqwest-012")] +impl TwirpHttpClient { + /// Builds a new client using [`reqwest 0.12`](reqwest_012). + /// + /// Note that `base_url` must be absolute with a scheme like `https://`. + /// + /// ``` + /// use twurst_client::TwirpHttpClient; + /// + /// let _client = TwirpHttpClient::new_using_reqwest_012("http://example.com/twirp"); + /// ``` + pub fn new_using_reqwest_012(base_url: impl Into) -> Self { + Self::new_with_reqwest_012_client(reqwest_012::Client::new(), base_url) + } + + /// Builds a new client using [`reqwest 0.12`](reqwest_012). + /// + /// Note that `base_url` must be absolute with a scheme like `https://`. + /// + /// ``` + /// # use reqwest_012::Client; + /// use twurst_client::TwirpHttpClient; + /// + /// let _client = + /// TwirpHttpClient::new_with_reqwest_012_client(Client::new(), "http://example.com/twirp"); + /// ``` + pub fn new_with_reqwest_012_client( + client: reqwest_012::Client, + base_url: impl Into, + ) -> Self { + Self::new_with_base(Reqwest012Service(client), base_url) + } +} + +impl TwirpHttpClient { + /// Builds a new client from a [`tower::Service`](Service) and a base URL to the Twirp endpoint. + /// + /// ``` + /// use http::Response; + /// use std::convert::Infallible; + /// use twurst_client::TwirpHttpClient; + /// use twurst_error::TwirpError; + /// + /// let _client = TwirpHttpClient::new_with_base( + /// tower::service_fn(|_request| async { + /// Ok::, Infallible>(TwirpError::unimplemented("not implemented").into()) + /// }), + /// "http://example.com/twirp", + /// ); + /// ``` + pub fn new_with_base(service: S, base_url: impl Into) -> Self { + let mut base_url = base_url.into(); + // We remove the last '/' to make concatenation work + if base_url.ends_with('/') { + base_url.pop(); + } + Self { + service, + base_url: Some(base_url), + use_json: false, + } + } + + /// New service without base URL. Relative URLs will be used for requests! + /// + /// ``` + /// use http::Response; + /// use std::convert::Infallible; + /// use twurst_client::TwirpHttpClient; + /// use twurst_error::TwirpError; + /// + /// let _client = TwirpHttpClient::new(tower::service_fn(|_request| async { + /// Ok::, Infallible>(TwirpError::unimplemented("not implemented").into()) + /// })); + /// ``` + pub fn new(service: S) -> Self { + Self { + service, + base_url: None, + use_json: false, + } + } + + /// Use JSON for requests and response instead of binary protobuf encoding that is used by default + pub fn use_json(&mut self) { + self.use_json = true; + } + + /// Use binary protobuf encoding for requests and response (the default) + pub fn use_binary_protobuf(&mut self) { + self.use_json = false; + } + + /// Send a Twirp request and get a response. + /// + /// Used internally by the generated code. + pub async fn call( + &self, + path: &str, + request: &I, + ) -> Result { + // We ensure that the service is ready + self.service.ready().await.map_err(|e| { + TwirpError::wrap( + TwirpErrorCode::Unknown, + format!("Service is not ready: {e}"), + e, + ) + })?; + let request = self.build_request(path, request)?; + let response = self.service.call(request).await.map_err(|e| { + TwirpError::wrap( + TwirpErrorCode::Unknown, + format!("Transport error during the request: {e}"), + e, + ) + })?; + self.extract_response(response).await + } + + fn build_request( + &self, + path: &str, + message: &T, + ) -> Result, TwirpError> { + let mut request_builder = Request::builder().method(Method::POST); + request_builder = if let Some(base_url) = &self.base_url { + request_builder.uri(format!("{}{}", base_url, path)) + } else { + request_builder.uri(path) + }; + if self.use_json { + request_builder + .header(CONTENT_TYPE, APPLICATION_JSON) + .body(json_encode(message)?.into()) + } else { + let mut buffer = BytesMut::with_capacity(message.encoded_len()); + message.encode(&mut buffer).map_err(|e| { + TwirpError::wrap( + TwirpErrorCode::Internal, + format!("Failed to serialize to protobuf: {e}"), + e, + ) + })?; + request_builder + .header(CONTENT_TYPE, APPLICATION_PROTOBUF) + .body(Bytes::from(buffer).into()) + } + .map_err(|e| { + TwirpError::wrap( + TwirpErrorCode::Malformed, + format!("Failed to construct request: {e}"), + e, + ) + }) + } + + async fn extract_response( + &self, + response: Response, + ) -> Result { + // We collect the body + // TODO: size limit + let (parts, body) = response.into_parts(); + let body = body.collect().await.map_err(|e| { + TwirpError::wrap( + TwirpErrorCode::Internal, + format!("Failed to load request body: {e}"), + e, + ) + })?; + let response = Response::from_parts(parts, body); + + // Error + if response.status() != StatusCode::OK { + return Err(response.map(|b| b.to_bytes()).into()); + } + + // Success + let content_type = response.headers().get(CONTENT_TYPE).cloned(); + let body = response.into_body(); + if content_type == Some(APPLICATION_PROTOBUF) { + T::decode(body.aggregate()).map_err(|e| { + TwirpError::wrap( + TwirpErrorCode::Malformed, + format!("Bad response binary protobuf encoding: {e}"), + e, + ) + }) + } else if content_type == Some(APPLICATION_JSON) { + json_decode(&body.to_bytes()) + } else if let Some(content_type) = content_type { + Err(TwirpError::malformed(format!( + "Unsupported response content-type: {}", + String::from_utf8_lossy(content_type.as_bytes()) + ))) + } else { + Err(TwirpError::malformed("No content-type in the response")) + } + } +} + +/// A service that can be used to send Twirp requests eg. an HTTP client +/// +/// Used by [`TwirpHttpClient`] to handle HTTP. +#[trait_variant::make(Send)] +pub trait TwirpHttpService: 'static { + type ResponseBody: Body; + type Error: Error + Send + Sync + 'static; + + async fn ready(&self) -> Result<(), Self::Error>; + + async fn call( + &self, + request: Request, + ) -> Result, Self::Error>; +} + +impl< + S: Service< + Request, + Error: Error + Send + Sync + 'static, + Response = Response, + Future: Send, + > + Clone + + Send + + Sync + + 'static, + RespBody: Body, + > TwirpHttpService for S +{ + type ResponseBody = RespBody; + type Error = S::Error; + + async fn ready(&self) -> Result<(), Self::Error> { + poll_fn(|cx| Service::poll_ready(&mut self.clone(), cx)).await + } + + async fn call( + &self, + request: Request, + ) -> Result, S::Error> { + Service::call(&mut self.clone(), request).await + } +} + +/// Request body for Twirp requests. +/// +/// It is a thin wrapper on top of [`Bytes`] to implement [`Body`]. +pub struct TwirpRequestBody(Bytes); + +impl From for TwirpRequestBody { + #[inline] + fn from(body: Bytes) -> Self { + Self(body) + } +} + +impl From for Bytes { + #[inline] + fn from(body: TwirpRequestBody) -> Self { + body.0 + } +} + +impl Body for TwirpRequestBody { + type Data = Bytes; + type Error = Infallible; + + #[inline] + fn poll_frame( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + let data = take(&mut self.0); + Poll::Ready(if data.has_remaining() { + Some(Ok(Frame::data(data))) + } else { + None + }) + } + + #[inline] + fn is_end_stream(&self) -> bool { + !self.0.has_remaining() + } + + #[inline] + fn size_hint(&self) -> SizeHint { + SizeHint::with_exact(self.0.remaining() as u64) + } +} + +fn json_encode(message: &T) -> Result { + let mut serializer = serde_json::Serializer::new(Vec::new()); + message + .transcode_to_dynamic() + .serialize(&mut serializer) + .map_err(|e| { + TwirpError::wrap( + TwirpErrorCode::Malformed, + format!("Failed to serialize request to JSON: {e}"), + e, + ) + })?; + Ok(serializer.into_inner().into()) +} + +fn json_decode(message: &[u8]) -> Result { + let dynamic_message = dynamic_json_decode::(message).map_err(|e| { + TwirpError::wrap( + TwirpErrorCode::Malformed, + format!("Failed to parse JSON response: {e}"), + e, + ) + })?; + dynamic_message.transcode_to().map_err(|e| { + TwirpError::internal(format!( + "Internal error while parsing the JSON response: {e}" + )) + }) +} + +fn dynamic_json_decode( + message: &[u8], +) -> Result { + let mut deserializer = serde_json::Deserializer::from_slice(message); + let dynamic_message = + DynamicMessage::deserialize(T::default().descriptor(), &mut deserializer)?; + deserializer.end()?; + Ok(dynamic_message) +} + +/// Wraps a [`reqwest::Client`](reqwest_012::Client) into a [`tower::Service`](Service) compatible with [`TwirpHttpClient`]. +#[cfg(feature = "reqwest-012")] +#[derive(Clone, Default)] +pub struct Reqwest012Service(reqwest_012::Client); + +#[cfg(feature = "reqwest-012")] +impl Reqwest012Service { + #[inline] + pub fn new() -> Self { + reqwest_012::Client::new().into() + } +} + +#[cfg(feature = "reqwest-012")] +impl From for Reqwest012Service { + #[inline] + fn from(client: reqwest_012::Client) -> Self { + Self(client) + } +} + +#[cfg(feature = "reqwest-012")] +impl> Service> for Reqwest012Service { + type Response = Response; + type Error = reqwest_012::Error; + type Future = Pin< + Box, reqwest_012::Error>> + Send>, + >; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.0.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + let req = match req.try_into() { + Ok(req) => req, + Err(e) => return Box::pin(async move { Err(e) }), + }; + let future = self.0.call(req); + Box::pin(async move { Ok(future.await?.into()) }) + } +} + +#[cfg(feature = "reqwest-012")] +impl From for reqwest_012::Body { + #[inline] + fn from(body: TwirpRequestBody) -> Self { + body.0.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(feature = "reqwest-012")] + use prost_reflect::prost::Message; + use prost_reflect::prost_types::Timestamp; + use std::future::Ready; + use std::io; + use std::task::{Context, Poll}; + use tower::service_fn; + + #[tokio::test] + async fn not_ready_service() -> Result<(), Box> { + #[derive(Clone)] + struct NotReadyService; + + impl Service for NotReadyService { + type Response = Response; + type Error = TwirpError; + type Future = Ready, TwirpError>>; + + fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Err(TwirpError::internal("foo"))) + } + + fn call(&mut self, _: S) -> Self::Future { + unimplemented!() + } + } + + let client = TwirpHttpClient::new(NotReadyService); + assert_eq!( + client + .call::<_, Timestamp>("", &Timestamp::default()) + .await + .unwrap_err() + .to_string(), + "Twirp Unknown error: Service is not ready: Twirp Internal error: foo" + ); + Ok(()) + } + + #[tokio::test] + async fn json_request_without_base_ok() -> Result<(), Box> { + let service = service_fn(|request: Request| async move { + assert_eq!(request.method(), Method::POST); + assert_eq!(request.uri(), "/foo"); + Ok::<_, TwirpError>( + Response::builder() + .header(CONTENT_TYPE, APPLICATION_JSON) + .body("\"1970-01-01T00:00:10Z\"".to_string()) + .unwrap(), + ) + }); + + let mut client = TwirpHttpClient::new(service); + client.use_json(); + let response = client + .call::<_, Timestamp>( + "/foo", + &Timestamp { + seconds: 10, + nanos: 0, + }, + ) + .await?; + assert_eq!( + response, + Timestamp { + seconds: 10, + nanos: 0 + } + ); + Ok(()) + } + + #[cfg(feature = "reqwest-012")] + #[tokio::test] + async fn binary_request_without_base_ok() -> Result<(), Box> { + let service = service_fn(|request: Request| async move { + assert_eq!(request.method(), Method::POST); + assert_eq!(request.uri(), "/foo"); + Ok::<_, TwirpError>( + Response::builder() + .header(CONTENT_TYPE, APPLICATION_PROTOBUF) + .body(reqwest_012::Body::from( + Timestamp { + seconds: 10, + nanos: 0, + } + .encode_to_vec(), + )) + .unwrap(), + ) + }); + + let response = TwirpHttpClient::new(service) + .call::<_, Timestamp>( + "/foo", + &Timestamp { + seconds: 10, + nanos: 0, + }, + ) + .await?; + assert_eq!( + response, + Timestamp { + seconds: 10, + nanos: 0 + } + ); + Ok(()) + } + + #[tokio::test] + async fn request_with_base_twirp_error() -> Result<(), Box> { + let service = service_fn(|request: Request| async move { + assert_eq!(request.method(), Method::POST); + assert_eq!(request.uri(), "http://example.com/twirp/foo"); + Ok::, TwirpError>(TwirpError::not_found("not found").into()) + }); + + let response_error = TwirpHttpClient::new_with_base(service, "http://example.com/twirp") + .call::<_, Timestamp>( + "/foo", + &Timestamp { + seconds: 10, + nanos: 0, + }, + ) + .await + .unwrap_err(); + assert_eq!(response_error, TwirpError::not_found("not found")); + Ok(()) + } + + #[tokio::test] + async fn request_with_base_other_error() -> Result<(), Box> { + let service = service_fn(|request: Request| async move { + assert_eq!(request.method(), Method::POST); + assert_eq!(request.uri(), "http://example.com/twirp/foo"); + Ok::, TwirpError>( + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body("foo".to_string()) + .unwrap(), + ) + }); + + let response_error = TwirpHttpClient::new_with_base(service, "http://example.com/twirp/") + .call::<_, Timestamp>( + "/foo", + &Timestamp { + seconds: 10, + nanos: 0, + }, + ) + .await + .unwrap_err(); + assert_eq!(response_error, TwirpError::unauthenticated("foo")); + Ok(()) + } + + #[tokio::test] + async fn request_transport_error() -> Result<(), Box> { + let service = service_fn(|request: Request| async move { + assert_eq!(request.method(), Method::POST); + assert_eq!(request.uri(), "/foo"); + Err::, _>(io::Error::other("Transport error")) + }); + + let response_error = TwirpHttpClient::new(service) + .call::<_, Timestamp>( + "/foo", + &Timestamp { + seconds: 10, + nanos: 0, + }, + ) + .await + .unwrap_err(); + assert_eq!( + response_error, + TwirpError::new( + TwirpErrorCode::Unknown, + "Transport error during the request: Transport error" + ) + ); + Ok(()) + } + + #[tokio::test] + async fn wrong_content_type_response() -> Result<(), Box> { + let service = service_fn(|request: Request| async move { + assert_eq!(request.method(), Method::POST); + assert_eq!(request.uri(), "/foo"); + Ok::, TwirpError>( + Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "foo/bar") + .body("foo".into()) + .unwrap(), + ) + }); + + let response_error = TwirpHttpClient::new(service) + .call::<_, Timestamp>( + "/foo", + &Timestamp { + seconds: 10, + nanos: 0, + }, + ) + .await + .unwrap_err(); + assert_eq!( + response_error, + TwirpError::malformed("Unsupported response content-type: foo/bar") + ); + Ok(()) + } + + #[tokio::test] + async fn invalid_protobuf_response() -> Result<(), Box> { + let service = service_fn(|request: Request| async move { + assert_eq!(request.method(), Method::POST); + assert_eq!(request.uri(), "/foo"); + Ok::, TwirpError>( + Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, APPLICATION_PROTOBUF) + .body("azerty".into()) + .unwrap(), + ) + }); + + let mut client = TwirpHttpClient::new(service); + client.use_json(); + let response_error = client + .call::<_, Timestamp>( + "/foo", + &Timestamp { + seconds: 10, + nanos: 0, + }, + ) + .await + .unwrap_err(); + assert_eq!( + response_error, + TwirpError::malformed("Bad response binary protobuf encoding: failed to decode Protobuf message: buffer underflow") + ); + Ok(()) + } + + #[tokio::test] + async fn invalid_json_response() -> Result<(), Box> { + let service = service_fn(|request: Request| async move { + assert_eq!(request.method(), Method::POST); + assert_eq!(request.uri(), "/foo"); + Ok::, TwirpError>( + Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, APPLICATION_JSON) + .body("foo".into()) + .unwrap(), + ) + }); + + let mut client = TwirpHttpClient::new(service); + client.use_json(); + let response_error = client + .call::<_, Timestamp>( + "/foo", + &Timestamp { + seconds: 10, + nanos: 0, + }, + ) + .await + .unwrap_err(); + assert_eq!( + response_error, + TwirpError::malformed( + "Failed to parse JSON response: expected ident at line 1 column 2" + ) + ); + Ok(()) + } + + #[tokio::test] + async fn response_future_is_send() { + fn is_send(_: T) {} + + let service = service_fn(|_: Request| async move { + Ok::<_, TwirpError>(Response::new(String::new())) + }); + let client = TwirpHttpClient::new(service); + + // This will fail to compile if the future is not Send + is_send(client.call::<_, Timestamp>("/foo", &Timestamp::default())); + } +} diff --git a/deny.toml b/deny.toml index 1025c0c..7b22a5a 100644 --- a/deny.toml +++ b/deny.toml @@ -2,6 +2,7 @@ allow = [ "Apache-2.0", "MIT", + "Unicode-3.0" ] [sources] diff --git a/error/Cargo.toml b/error/Cargo.toml index 1949ca9..acf3b61 100644 --- a/error/Cargo.toml +++ b/error/Cargo.toml @@ -4,3 +4,15 @@ description = "Twirp error struct" edition.workspace = true version.workspace = true license.workspace = true +rust-version.workspace = true + +[features] +axum-07 = ["dep:axum-07", "http"] +http = ["dep:http", "dep:serde_json", "serde"] +serde = ["dep:serde"] + +[dependencies] +axum-07 = { workspace = true, optional = true } +http = { workspace = true, optional = true } +serde = { workspace = true, features = ["derive"], optional = true } +serde_json = { workspace = true, optional = true } diff --git a/error/README.md b/error/README.md index 7514f54..dcd5b32 100644 --- a/error/README.md +++ b/error/README.md @@ -1,6 +1,12 @@ Small library implementing the `TwirpError` struct. Please don't use it directly but rely on `twurst-client` or `twurst-server`that re-export this type. +## Cargo features +- `serde` allows to (de)serialize the error using [Serde](https://serde.rs/) following the official Twirp serialization. +- `http` allows to convert between [`http::Response`](https://docs.rs/http/latest/http/response/struct.Response.html) objects and Twirp errors, + properly deserializing the error if possible, or building an as good as possible equivalent if not. +- `axum-07` implements the [`axum::response::IntoResponse`](https://docs.rs/axum/latest/axum/response/trait.IntoResponse.html) trait on [`TwirpError`]. + ## License Copyright 2024 Helsing GmbH diff --git a/error/src/lib.rs b/error/src/lib.rs index b09bea3..7d6d789 100644 --- a/error/src/lib.rs +++ b/error/src/lib.rs @@ -22,13 +22,19 @@ use std::sync::Arc; /// assert_eq!(error.meta("id"), Some("foo")); /// ``` #[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct TwirpError { /// The [error code](https://twitchtv.github.io/twirp/docs/spec_v7.html#error-codes) code: TwirpErrorCode, /// The error message (human description of the error) msg: String, /// Some metadata describing the error + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "HashMap::is_empty") + )] meta: HashMap, + #[cfg_attr(feature = "serde", serde(default, skip))] source: Option>, } @@ -202,6 +208,8 @@ impl Eq for TwirpError {} /// A Twirp [error code](https://twitchtv.github.io/twirp/docs/spec_v7.html#error-codes) #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] pub enum TwirpErrorCode { /// The operation was cancelled. Canceled, @@ -240,3 +248,156 @@ pub enum TwirpErrorCode { /// The operation resulted in unrecoverable data loss or corruption. Dataloss, } + +/// Applies the mapping defined in [Twirp spec](https://twitchtv.github.io/twirp/docs/spec_v7.html#error-codes) +#[cfg(feature = "http")] +impl From for http::StatusCode { + #[inline] + fn from(code: TwirpErrorCode) -> Self { + match code { + TwirpErrorCode::Canceled => Self::REQUEST_TIMEOUT, + TwirpErrorCode::Unknown => Self::INTERNAL_SERVER_ERROR, + TwirpErrorCode::InvalidArgument => Self::BAD_REQUEST, + TwirpErrorCode::Malformed => Self::BAD_REQUEST, + TwirpErrorCode::DeadlineExceeded => Self::REQUEST_TIMEOUT, + TwirpErrorCode::NotFound => Self::NOT_FOUND, + TwirpErrorCode::BadRoute => Self::NOT_FOUND, + TwirpErrorCode::AlreadyExists => Self::CONFLICT, + TwirpErrorCode::PermissionDenied => Self::FORBIDDEN, + TwirpErrorCode::Unauthenticated => Self::UNAUTHORIZED, + TwirpErrorCode::ResourceExhausted => Self::TOO_MANY_REQUESTS, + TwirpErrorCode::FailedPrecondition => Self::PRECONDITION_FAILED, + TwirpErrorCode::Aborted => Self::CONFLICT, + TwirpErrorCode::OutOfRange => Self::BAD_REQUEST, + TwirpErrorCode::Unimplemented => Self::NOT_IMPLEMENTED, + TwirpErrorCode::Internal => Self::INTERNAL_SERVER_ERROR, + TwirpErrorCode::Unavailable => Self::SERVICE_UNAVAILABLE, + TwirpErrorCode::Dataloss => Self::SERVICE_UNAVAILABLE, + } + } +} + +#[cfg(feature = "http")] +impl> From for http::Response { + fn from(error: TwirpError) -> Self { + let json = serde_json::to_string(&error).unwrap(); + http::Response::builder() + .status(error.code) + .header(http::header::CONTENT_TYPE, "application/json") + .extension(error) + .body(json.into()) + .unwrap() + } +} + +#[cfg(feature = "http")] +impl> From> for TwirpError { + fn from(response: http::Response) -> Self { + if let Some(error) = response.extensions().get::() { + // We got a ready to use error in the extensions, let's use it + return error.clone(); + } + // We are lenient here, a bad error is better than no error at all + let status = response.status(); + let body = response.into_body(); + if let Ok(error) = serde_json::from_slice::(body.as_ref()) { + // The body is an error, we use it + return error; + } + // We don't have a Twirp error, we build a fallback + let code = if status == http::StatusCode::REQUEST_TIMEOUT { + TwirpErrorCode::DeadlineExceeded + } else if status == http::StatusCode::FORBIDDEN { + TwirpErrorCode::PermissionDenied + } else if status == http::StatusCode::UNAUTHORIZED { + TwirpErrorCode::Unauthenticated + } else if status == http::StatusCode::TOO_MANY_REQUESTS { + TwirpErrorCode::ResourceExhausted + } else if status == http::StatusCode::PRECONDITION_FAILED { + TwirpErrorCode::FailedPrecondition + } else if status == http::StatusCode::NOT_IMPLEMENTED { + TwirpErrorCode::Unimplemented + } else if status == http::StatusCode::TOO_MANY_REQUESTS + || status == http::StatusCode::BAD_GATEWAY + || status == http::StatusCode::SERVICE_UNAVAILABLE + || status == http::StatusCode::GATEWAY_TIMEOUT + { + TwirpErrorCode::Unavailable + } else if status == http::StatusCode::NOT_FOUND { + TwirpErrorCode::NotFound + } else if status.is_server_error() { + TwirpErrorCode::Internal + } else if status.is_client_error() { + TwirpErrorCode::Malformed + } else { + TwirpErrorCode::Unknown + }; + TwirpError::new(code, String::from_utf8_lossy(body.as_ref())) + } +} + +#[cfg(feature = "axum-07")] +impl axum_07::response::IntoResponse for TwirpError { + #[inline] + fn into_response(self) -> axum_07::response::Response { + self.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(feature = "http")] + use std::error::Error; + + #[test] + fn test_accessors() { + let error = TwirpError::invalid_argument("foo", "foo is wrong"); + assert_eq!(error.code(), TwirpErrorCode::InvalidArgument); + assert_eq!(error.message(), "foo is wrong"); + assert_eq!(error.meta("argument"), Some("foo")); + } + + #[cfg(feature = "http")] + #[test] + fn test_to_response() -> Result<(), Box> { + let object = + TwirpError::permission_denied("Thou shall not pass").with_meta("target", "Balrog"); + let response = http::Response::>::from(object); + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + assert_eq!( + response.headers().get(http::header::CONTENT_TYPE), + Some(&http::HeaderValue::from_static("application/json")) + ); + assert_eq!( + response.into_body(), b"{\"code\":\"permission_denied\",\"msg\":\"Thou shall not pass\",\"meta\":{\"target\":\"Balrog\"}}" + ); + Ok(()) + } + + #[cfg(feature = "http")] + #[test] + fn test_from_valid_response() -> Result<(), Box> { + let response = http::Response::builder() + .header(http::header::CONTENT_TYPE, "application/json") + .body("{\"code\":\"permission_denied\",\"msg\":\"Thou shall not pass\",\"meta\":{\"target\":\"Balrog\"}}")?; + assert_eq!( + TwirpError::from(response), + TwirpError::permission_denied("Thou shall not pass").with_meta("target", "Balrog") + ); + Ok(()) + } + + #[cfg(feature = "http")] + #[test] + fn test_from_plain_response() -> Result<(), Box> { + let response = http::Response::builder() + .status(http::StatusCode::FORBIDDEN) + .body("Thou shall not pass")?; + assert_eq!( + TwirpError::from(response), + TwirpError::permission_denied("Thou shall not pass") + ); + Ok(()) + } +}