diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index db412090..89bea614 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,8 @@ on: - release-* pull_request: workflow_dispatch: +env: + RUSTFLAGS: "--cfg async_executor_impl=\"async-std\" --cfg async_channel_impl=\"async-std\"" jobs: build: @@ -38,8 +40,8 @@ jobs: - name: Test run: | - cargo test --workspace --release --no-run - cargo test --workspace --release --verbose -- --test-threads 2 + cargo test --workspace --release --all-features --no-run + cargo test --workspace --release --all-features --verbose -- --test-threads 2 timeout-minutes: 30 - name: Generate Documentation diff --git a/.github/workflows/build_windows.yml b/.github/workflows/build_windows.yml index c2d6a303..eb0e7a1c 100644 --- a/.github/workflows/build_windows.yml +++ b/.github/workflows/build_windows.yml @@ -18,6 +18,8 @@ on: - main - release-* workflow_dispatch: +env: + RUSTFLAGS: "--cfg async_executor_impl=\"async-std\" --cfg async_channel_impl=\"async-std\"" jobs: windows: @@ -35,6 +37,6 @@ jobs: - name: Test run: | - cargo test --workspace --release --no-run - cargo test --workspace --release --verbose -- --test-threads 2 + cargo test --workspace --release --all-features --no-run + cargo test --workspace --release --all-features --verbose -- --test-threads 2 timeout-minutes: 30 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 32366d28..64865260 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -5,6 +5,8 @@ on: branches: - main workflow_dispatch: +env: + RUSTFLAGS: "--cfg async_executor_impl=\"async-std\" --cfg async_channel_impl=\"async-std\"" jobs: coverage: @@ -24,7 +26,7 @@ jobs: - name: Generate code coverage run: | mkdir coverage - cargo llvm-cov --workspace --lcov --output-path ./coverage/lcov.info + cargo llvm-cov --workspace --all-features --lcov --output-path ./coverage/lcov.info timeout-minutes: 240 - name: Coveralls upload diff --git a/Cargo.lock b/Cargo.lock index 77cc341f..e0164814 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -203,6 +203,26 @@ dependencies = [ "futures-core", ] +[[package]] +name = "async-compatibility-layer" +version = "1.0.0" +source = "git+https://github.com/EspressoSystems/async-compatibility-layer.git?tag=1.4.2#482cece7d27f20d5a543e209a5d88b1837778b4c" +dependencies = [ + "async-channel", + "async-lock", + "async-std", + "async-trait", + "color-eyre", + "console-subscriber", + "flume", + "futures", + "tokio", + "tokio-stream", + "tracing", + "tracing-error", + "tracing-subscriber", +] + [[package]] name = "async-dup" version = "1.2.2" @@ -222,7 +242,7 @@ dependencies = [ "async-lock", "async-task", "concurrent-queue", - "fastrand", + "fastrand 1.9.0", "futures-lite", "slab", ] @@ -240,6 +260,7 @@ dependencies = [ "blocking", "futures-lite", "once_cell", + "tokio", ] [[package]] @@ -272,7 +293,7 @@ dependencies = [ "log", "parking", "polling", - "rustix", + "rustix 0.37.25", "slab", "socket2 0.4.9", "waker-fn", @@ -300,7 +321,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 0.37.25", "signal-hook", "windows-sys 0.48.0", ] @@ -347,6 +368,28 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "async-stream" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite 0.2.12", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.53", +] + [[package]] name = "async-task" version = "4.4.0" @@ -403,6 +446,51 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http 0.2.11", + "http-body 0.4.6", + "hyper 0.14.28", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite 0.2.12", + "rustversion", + "serde", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 0.2.11", + "http-body 0.4.6", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + [[package]] name = "backtrace" version = "0.3.68" @@ -488,7 +576,7 @@ dependencies = [ "async-lock", "async-task", "atomic-waker", - "fastrand", + "fastrand 1.9.0", "futures-lite", "log", ] @@ -505,12 +593,6 @@ version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" -[[package]] -name = "bytes" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4cec68f03f32e44924783795810fa50a7035d8c8ebe78580ad7e6c703fba38" - [[package]] name = "bytes" version = "1.4.0" @@ -594,6 +676,33 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" +[[package]] +name = "color-eyre" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55146f5e46f237f7423d74111267d4597b59b0dad0ffaf7303bce9945d843ad5" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "colorchoice" version = "1.0.0" @@ -629,6 +738,43 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "console-api" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd326812b3fd01da5bb1af7d340d0d555fd3d4b641e7f1dfcf5962a902952787" +dependencies = [ + "futures-core", + "prost", + "prost-types", + "tonic", + "tracing-core", +] + +[[package]] +name = "console-subscriber" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7481d4c57092cd1c19dd541b92bdce883de840df30aa5d03fd48a3935c01842e" +dependencies = [ + "console-api", + "crossbeam-channel", + "crossbeam-utils", + "futures-task", + "hdrhistogram", + "humantime", + "prost-types", + "serde", + "serde_json", + "thread_local", + "tokio", + "tokio-stream", + "tonic", + "tracing", + "tracing-core", + "tracing-subscriber", +] + [[package]] name = "const-random" version = "0.1.18" @@ -687,11 +833,21 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" -version = "0.8.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" [[package]] name = "cpufeatures" @@ -714,6 +870,25 @@ version = "2.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "774646b687f63643eb0f4bf13dc263cb581c8c9e57973b6ddf78bda3994d88df" +[[package]] +name = "crc32fast" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.16" @@ -758,37 +933,6 @@ dependencies = [ "cipher", ] -[[package]] -name = "curl" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e2161dd6eba090ff1594084e95fd67aeccf04382ffea77999ea94ed42ec67b6" -dependencies = [ - "curl-sys", - "libc", - "openssl-probe", - "openssl-sys", - "schannel", - "socket2 0.5.6", - "windows-sys 0.52.0", -] - -[[package]] -name = "curl-sys" -version = "0.4.72+curl-8.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29cbdc8314c447d11e8fd156dcdd031d9e02a7a976163e396b548c03153bc9ea" -dependencies = [ - "cc", - "libc", - "libnghttp2-sys", - "libz-sys", - "openssl-sys", - "pkg-config", - "vcpkg", - "windows-sys 0.52.0", -] - [[package]] name = "darling" version = "0.20.3" @@ -973,23 +1117,12 @@ dependencies = [ [[package]] name = "errno" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b30f669a7961ef1631673d2766cc92f52d64f7ef354d4fe0ddfd30ed52f0f4f" -dependencies = [ - "errno-dragonfly", - "libc", - "windows-sys 0.48.0", -] - -[[package]] -name = "errno-dragonfly" -version = "0.1.2" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" dependencies = [ - "cc", "libc", + "windows-sys 0.52.0", ] [[package]] @@ -998,6 +1131,16 @@ version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fastrand" version = "1.9.0" @@ -1007,15 +1150,32 @@ dependencies = [ "instant", ] +[[package]] +name = "fastrand" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" + +[[package]] +name = "flate2" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" -version = "0.9.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bebadab126f8120d410b677ed95eee4ba6eb7c6dd8e34a5ec88a08050e26132" +checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" dependencies = [ "futures-core", "futures-sink", - "spinning_top", + "nanorand", + "spin", ] [[package]] @@ -1024,6 +1184,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -1087,7 +1262,7 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" dependencies = [ - "fastrand", + "fastrand 1.9.0", "futures-core", "futures-io", "memchr", @@ -1165,8 +1340,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1197,6 +1374,44 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "h2" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fbd2820c5e49886948654ab546d0688ff24530286bdcf8fca3cefb16d4618eb" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.11", + "indexmap 2.0.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51ee2dd2e4f378392eeff5d51618cd9a63166a2513846bbc55f21cfacd9199d4" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 1.0.0", + "indexmap 2.0.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1215,6 +1430,19 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" +[[package]] +name = "hdrhistogram" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +dependencies = [ + "base64 0.21.2", + "byteorder", + "flate2", + "nom", + "num-traits", +] + [[package]] name = "heck" version = "0.4.1" @@ -1265,7 +1493,7 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" dependencies = [ - "bytes 1.4.0", + "bytes", "fnv", "itoa", ] @@ -1276,22 +1504,54 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b32afd38673a8016f7c9ae69e5af41a58f81b1d31689040f2f1959594ce194ea" dependencies = [ - "bytes 1.4.0", + "bytes", "fnv", "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.11", + "pin-project-lite 0.2.12", +] + +[[package]] +name = "http-body" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +dependencies = [ + "bytes", + "http 1.0.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d" +dependencies = [ + "bytes", + "futures-core", + "http 1.0.0", + "http-body 1.0.0", + "pin-project-lite 0.2.12", +] + [[package]] name = "http-client" version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1947510dc91e2bf586ea5ffb412caad7673264e14bb39fb9078da114a94ce1a5" dependencies = [ - "async-std", "async-trait", "cfg-if", "http-types", - "isahc", "log", ] @@ -1323,6 +1583,110 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "hyper" +version = "0.14.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.25", + "http 0.2.11", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite 0.2.12", + "socket2 0.5.6", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186548d73ac615b32a73aafe38fb4f56c0d340e110e5a200bcadbaf2e199263a" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2 0.4.3", + "http 1.0.0", + "http-body 1.0.0", + "httparse", + "itoa", + "pin-project-lite 0.2.12", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper 0.14.28", + "pin-project-lite 0.2.12", + "tokio", + "tokio-io-timeout", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.2.0", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.0.0", + "http-body 1.0.0", + "hyper 1.2.0", + "pin-project-lite 0.2.12", + "socket2 0.5.6", + "tokio", + "tower", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.57" @@ -1381,6 +1745,12 @@ dependencies = [ "quote", ] +[[package]] +name = "indenter" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" + [[package]] name = "indexmap" version = "1.9.3" @@ -1415,7 +1785,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f97967975f448f1a7ddb12b0bc41069d09ed6a1c161a92687e057325db35d413" dependencies = [ - "bytes 1.4.0", + "bytes", ] [[package]] @@ -1439,27 +1809,10 @@ dependencies = [ ] [[package]] -name = "isahc" -version = "0.9.14" +name = "ipnet" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2948a0ce43e2c2ef11d7edf6816508998d99e13badd1150be0914205df9388a" -dependencies = [ - "bytes 0.5.6", - "crossbeam-utils", - "curl", - "curl-sys", - "flume", - "futures-lite", - "http 0.2.11", - "log", - "once_cell", - "slab", - "sluice", - "tracing", - "tracing-futures", - "url", - "waker-fn", -] +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" [[package]] name = "itertools" @@ -1470,6 +1823,24 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.9" @@ -1517,28 +1888,6 @@ version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" -[[package]] -name = "libnghttp2-sys" -version = "0.1.8+1.55.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fae956c192dadcdb5dace96db71fa0b827333cce7c7b38dc71446f024d8a340" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "libz-sys" -version = "1.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d97137b25e321a73eef1418d1d5d2eda4d77e12813f8e6dead84bc52c5870a7b" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "linked-hash-map" version = "0.5.6" @@ -1551,6 +1900,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" +[[package]] +name = "linux-raw-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" + [[package]] name = "lock_api" version = "0.4.10" @@ -1590,6 +1945,12 @@ dependencies = [ "regex-automata 0.1.10", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "maud" version = "0.26.0" @@ -1625,16 +1986,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1650,6 +2001,44 @@ dependencies = [ "adler", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.10", +] + +[[package]] +name = "native-tls" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nom" version = "7.1.3" @@ -1711,6 +2100,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "object" version = "0.31.1" @@ -1732,6 +2131,32 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +[[package]] +name = "openssl" +version = "0.10.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" +dependencies = [ + "bitflags 2.4.2", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.53", +] + [[package]] name = "openssl-probe" version = "0.1.5" @@ -1740,9 +2165,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.91" +version = "0.9.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "866b5f16f90776b9bb8dc1e1802ac6f0513de3a7a7465867bfbc563dc737faac" +checksum = "dda2b0f344e78efc2facf7d195d098df0dd72151b26ab98da807afc26c198dff" dependencies = [ "cc", "libc", @@ -1772,6 +2197,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + [[package]] name = "parking" version = "2.1.0" @@ -1982,24 +2413,56 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" name = "proc-macro2" version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prometheus" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "449811d15fbdf5ceb5c1144416066429cf82316e2ec8ce0c1f6f8a02e7bbcf8c" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "memchr", + "parking_lot", + "protobuf", + "thiserror", +] + +[[package]] +name = "prost" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c289cda302b98a28d40c8b3b90498d6e526dd24ac2ecea73e4e491685b94a" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efb6c9a1dd1def8e2124d17e83a20af56f1570d6c2d2bd9e266ccb768df3840e" dependencies = [ - "unicode-ident", + "anyhow", + "itertools 0.11.0", + "proc-macro2", + "quote", + "syn 2.0.53", ] [[package]] -name = "prometheus" -version = "0.13.3" +name = "prost-types" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "449811d15fbdf5ceb5c1144416066429cf82316e2ec8ce0c1f6f8a02e7bbcf8c" +checksum = "193898f59edcf43c26227dcd4c8427f00d99d61e95dcde58dabd49fa291d470e" dependencies = [ - "cfg-if", - "fnv", - "lazy_static", - "memchr", - "parking_lot", - "protobuf", - "thiserror", + "prost", ] [[package]] @@ -2161,6 +2624,48 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2" +[[package]] +name = "reqwest" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d66674f2b6fb864665eea7a3c1ac4e3dfacd2fda83cf6f935a612e01b0e3338" +dependencies = [ + "base64 0.21.2", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.4.3", + "http 1.0.0", + "http-body 1.0.0", + "http-body-util", + "hyper 1.2.0", + "hyper-tls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite 0.2.12", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "ron" version = "0.8.1" @@ -2233,10 +2738,32 @@ dependencies = [ "errno", "io-lifetimes", "libc", - "linux-raw-sys", + "linux-raw-sys 0.3.8", "windows-sys 0.48.0", ] +[[package]] +name = "rustix" +version = "0.38.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" +dependencies = [ + "bitflags 2.4.2", + "errno", + "libc", + "linux-raw-sys 0.4.13", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.2", +] + [[package]] name = "rustversion" version = "1.0.14" @@ -2264,6 +2791,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "0.9.0" @@ -2517,22 +3067,11 @@ dependencies = [ "autocfg", ] -[[package]] -name = "sluice" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5" -dependencies = [ - "async-channel", - "futures-core", - "futures-io", -] - [[package]] name = "smallvec" -version = "1.11.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "smartcow" @@ -2619,10 +3158,10 @@ dependencies = [ ] [[package]] -name = "spinning_top" -version = "0.2.5" +name = "spin" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b9eb1a2f4c41445a3a0ff9abc5221c5fcd28e1f13cd7c0397706f9ac938ddb0" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" dependencies = [ "lock_api", ] @@ -2728,29 +3267,6 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" -[[package]] -name = "surf" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "718b1ae6b50351982dedff021db0def601677f2120938b070eadb10ba4038dd7" -dependencies = [ - "async-std", - "async-trait", - "cfg-if", - "encoding_rs", - "futures-util", - "getrandom 0.2.10", - "http-client", - "http-types", - "log", - "mime_guess", - "once_cell", - "pin-project-lite 0.2.12", - "serde", - "serde_json", - "web-sys", -] - [[package]] name = "sval" version = "2.6.1" @@ -2841,6 +3357,33 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tagged-base64" version = "0.3.4" @@ -2865,6 +3408,18 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand 2.0.2", + "rustix 0.38.32", + "windows-sys 0.52.0", +] + [[package]] name = "thiserror" version = "1.0.46" @@ -2923,6 +3478,7 @@ dependencies = [ "anyhow", "ark-serialize", "ark-std", + "async-compatibility-layer", "async-std", "async-trait", "async-tungstenite 0.25.0", @@ -2936,6 +3492,7 @@ dependencies = [ "futures-util", "http 1.0.0", "include_dir", + "itertools 0.12.1", "lazy_static", "libc", "markdown", @@ -2945,6 +3502,7 @@ dependencies = [ "parking_lot", "portpicker", "prometheus", + "reqwest", "routefinder", "semver 1.0.22", "serde", @@ -2956,7 +3514,6 @@ dependencies = [ "snafu 0.8.2", "strum", "strum_macros", - "surf", "tagged-base64", "tide", "tide-websockets", @@ -2966,7 +3523,6 @@ dependencies = [ "tracing-futures", "tracing-log", "tracing-subscriber", - "tracing-test", "url", "versioned-binary-serialization", ] @@ -3079,6 +3635,82 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "parking_lot", + "pin-project-lite 0.2.12", + "signal-hook-registry", + "socket2 0.5.6", + "tokio-macros", + "tracing", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-io-timeout" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b74022ada614a1b4834de765f9bb43877f910cc8ce4be40e89042c9223a8bf" +dependencies = [ + "pin-project-lite 0.2.12", + "tokio", +] + +[[package]] +name = "tokio-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.53", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" +dependencies = [ + "futures-core", + "pin-project-lite 0.2.12", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite 0.2.12", + "tokio", + "tracing", +] + [[package]] name = "toml" version = "0.8.8" @@ -3113,6 +3745,65 @@ dependencies = [ "winnow", ] +[[package]] +name = "tonic" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d560933a0de61cf715926b9cac824d4c883c2c43142f787595e48280c40a1d0e" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.21.2", + "bytes", + "h2 0.3.25", + "http 0.2.11", + "http-body 0.4.6", + "hyper 0.14.28", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite 0.2.12", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + [[package]] name = "tracing" version = "0.1.40" @@ -3152,12 +3843,22 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de30b98573d9e63e82996b3c9bf950210ba3f2dcf363f7eec000acebef1a4377" dependencies = [ - "itertools", + "itertools 0.9.0", "tracing", "tracing-core", "tracing-subscriber", ] +[[package]] +name = "tracing-error" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e" +dependencies = [ + "tracing", + "tracing-subscriber", +] + [[package]] name = "tracing-futures" version = "0.2.5" @@ -3211,27 +3912,10 @@ dependencies = [ ] [[package]] -name = "tracing-test" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a2c0ff408fe918a94c428a3f2ad04e4afd5c95bbc08fcf868eff750c15728a4" -dependencies = [ - "lazy_static", - "tracing-core", - "tracing-subscriber", - "tracing-test-macro", -] - -[[package]] -name = "tracing-test-macro" -version = "0.2.4" +name = "try-lock" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "258bc1c4f8e2e73a977812ab339d503e6feeb92700f6d07a6de4d321522d5c08" -dependencies = [ - "lazy_static", - "quote", - "syn 1.0.109", -] +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" @@ -3241,7 +3925,7 @@ checksum = "5fe8dada8c1a3aeca77d6b51a4f1314e0f4b8e438b7b1b71e3ddaca8080e4093" dependencies = [ "base64 0.13.1", "byteorder", - "bytes 1.4.0", + "bytes", "http 0.2.11", "httparse", "input_buffer", @@ -3260,7 +3944,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" dependencies = [ "byteorder", - "bytes 1.4.0", + "bytes", "data-encoding", "http 1.0.0", "httparse", @@ -3284,15 +3968,6 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" -[[package]] -name = "unicase" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" -dependencies = [ - "version_check", -] - [[package]] name = "unicode-bidi" version = "0.3.13" @@ -3426,6 +4101,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca" +[[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.9.0+wasi-snapshot-preview1" @@ -3688,6 +4372,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "yaml-rust" version = "0.4.5" diff --git a/Cargo.toml b/Cargo.toml index c73d7b17..886aa98d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,13 +7,22 @@ description = "Discoverability for Tide" repository = "https://github.com/EspressoSystems/tide-disco" license-file = "LICENSE" +[features] +testing = ["async-compatibility-layer", "async-tungstenite"] + [[example]] name = "hello-world" test = true +required-features = ["testing"] + +[[example]] +name = "versions" +test = true +required-features = ["testing"] [dependencies] anyhow = "1.0" -async-std = { version = "1.12", features = ["attributes"] } +async-std = { version = "1.12", features = ["attributes", "tokio1"] } async-trait = "0.1.78" clap = { version = "4.5", features = ["derive"] } config = "0.14" @@ -25,6 +34,7 @@ futures = "0.3.30" futures-util = "0.3.30" http = "1.0.0" include_dir = "0.7" +itertools = "0.12" lazy_static = "1.4" libc = "0.2" markdown = "0.3" @@ -33,6 +43,7 @@ num-derive = "0.4" num-traits = "0.2" parking_lot = "0.12" prometheus = "0.13" +reqwest = { version = "0.12", features = ["json"] } routefinder = "0.5" semver = "1.0" serde = { version = "1.0", features = ["derive"] } @@ -43,7 +54,6 @@ signal-hook = "0.3.14" snafu = "0.8" strum = "0.26" strum_macros = "0.26" -surf = "2.3.2" tagged-base64 = { git = "https://github.com/EspressoSystems/tagged-base64.git", tag = "0.3.4" } tide = { version = "0.16.0", default-features = false } tide-websockets = "0.4.0" @@ -56,12 +66,16 @@ tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json"] } url = "2.5.0" versioned-binary-serialization = { git = "https://github.com/EspressoSystems/versioned-binary-serialization.git", tag = "0.1.2" } +# Dependencies enabled by feature `testing` +async-compatibility-layer = { git = "https://github.com/EspressoSystems/async-compatibility-layer.git", tag = "1.4.2", features = ["logging-utils"], optional = true } +async-tungstenite = { version = "0.25", features = ["async-std-runtime"], optional = true } + [target.'cfg(not(windows))'.dependencies] signal-hook-async-std = "0.2.2" [dev-dependencies] ark-serialize = { version = "0.4", features = ["derive"] } ark-std = "0.4.0" +async-compatibility-layer = { git = "https://github.com/EspressoSystems/async-compatibility-layer.git", tag = "1.4.2", features = ["logging-utils"] } async-tungstenite = { version = "0.25", features = ["async-std-runtime"] } portpicker = "0.1" -tracing-test = "0.2" diff --git a/examples/hello-world/main.rs b/examples/hello-world/main.rs index 85657861..7772809f 100644 --- a/examples/hello-world/main.rs +++ b/examples/hello-world/main.rs @@ -98,117 +98,92 @@ mod test { use super::*; use async_std::task::spawn; use portpicker::pick_unused_port; - use surf::Url; use tide_disco::{ api::ApiVersion, app::{AppHealth, AppVersion}, healthcheck::HealthStatus, - wait_for_server, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS, + testing::{setup_test, Client}, + Url, }; - use tracing_test::traced_test; #[async_std::test] - #[traced_test] async fn test_get_set_greeting() { + setup_test(); + let port = pick_unused_port().unwrap(); spawn(serve(port)); let url = Url::parse(&format!("http://localhost:{}/hello/", port)).unwrap(); - wait_for_server(&url, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS).await; + let client = Client::new(url).await; - let mut res = surf::get(url.join("greeting/tester").unwrap()) - .send() - .await - .unwrap(); + let res = client.get("greeting/tester").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); - assert_eq!(res.body_json::().await.unwrap(), "Hello, tester"); + assert_eq!(res.json::().await.unwrap(), "Hello, tester"); - let res = surf::post(url.join("greeting/Sup").unwrap()) - .send() - .await - .unwrap(); + let res = client.post("greeting/Sup").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); - let mut res = surf::get(url.join("greeting/tester").unwrap()) - .send() - .await - .unwrap(); + let res = client.get("greeting/tester").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); - assert_eq!(res.body_json::().await.unwrap(), "Sup, tester"); + assert_eq!(res.json::().await.unwrap(), "Sup, tester"); } #[async_std::test] - #[traced_test] async fn test_version() { + setup_test(); + let port = pick_unused_port().unwrap(); spawn(serve(port)); let url = Url::parse(&format!("http://localhost:{}/", port)).unwrap(); - wait_for_server(&url, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS).await; + let client = Client::new(url).await; // Check the API version. - let mut res = surf::get(url.join("hello/version").unwrap()) - .send() - .await - .unwrap(); + let res = client.get("hello/version").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); let api_version = ApiVersion { api_version: Some(env!("CARGO_PKG_VERSION").parse().unwrap()), spec_version: "0.1.0".parse().unwrap(), }; - assert_eq!(res.body_json::().await.unwrap(), api_version); + assert_eq!(res.json::().await.unwrap(), api_version); // Check the overall version. - let mut res = surf::get(url.join("version").unwrap()) - .send() - .await - .unwrap(); + let res = client.get("version").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); assert_eq!( - res.body_json::().await.unwrap(), + res.json::().await.unwrap(), AppVersion { app_version: Some(env!("CARGO_PKG_VERSION").parse().unwrap()), disco_version: env!("CARGO_PKG_VERSION").parse().unwrap(), - modules: [("hello".to_string(), api_version)] - .iter() - .cloned() - .collect(), + modules: [("hello".to_string(), vec![api_version])].into() } ) } #[async_std::test] - #[traced_test] async fn test_healthcheck() { + setup_test(); + let port = pick_unused_port().unwrap(); spawn(serve(port)); let url = Url::parse(&format!("http://localhost:{}/", port)).unwrap(); - wait_for_server(&url, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS).await; + let client = Client::new(url).await; // Check the API health. - let mut res = surf::get(url.join("hello/healthcheck").unwrap()) - .send() - .await - .unwrap(); + let res = client.get("hello/healthcheck").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); // The example API does not have a custom healthcheck, so we just get the default response. assert_eq!( - res.body_json::().await.unwrap(), + res.json::().await.unwrap(), HealthStatus::Available ); // Check the overall health. - let mut res = surf::get(url.join("healthcheck").unwrap()) - .send() - .await - .unwrap(); + let res = client.get("healthcheck").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); assert_eq!( - res.body_json::().await.unwrap(), + res.json::().await.unwrap(), AppHealth { status: HealthStatus::Available, - modules: [("hello".to_string(), StatusCode::Ok)] - .iter() - .cloned() - .collect(), + modules: [("hello".to_string(), [(0, StatusCode::Ok)].into())].into(), } ) } diff --git a/examples/versions/main.rs b/examples/versions/main.rs new file mode 100644 index 00000000..97b780a0 --- /dev/null +++ b/examples/versions/main.rs @@ -0,0 +1,118 @@ +// Copyright (c) 2022 Espresso Systems (espressosys.com) +// This file is part of the tide-disco library. + +// You should have received a copy of the MIT License +// along with the tide-disco library. If not, see . + +use futures::FutureExt; +use std::io; +use tide_disco::{error::ServerError, Api, App}; +use versioned_binary_serialization::version::StaticVersion; + +type StaticVer01 = StaticVersion<0, 1>; +const STATIC_VER: StaticVer01 = StaticVersion {}; + +async fn serve(port: u16) -> io::Result<()> { + let mut app = App::<_, ServerError, StaticVer01>::with_state(()); + app.with_version(env!("CARGO_PKG_VERSION").parse().unwrap()); + + let mut v1 = + Api::<(), ServerError, StaticVer01>::from_file("examples/versions/v1.toml").unwrap(); + v1.with_version("1.0.0".parse().unwrap()) + .get("deleted", |_, _| async move { Ok("deleted") }.boxed()) + .unwrap(); + + let mut v2 = + Api::<(), ServerError, StaticVer01>::from_file("examples/versions/v2.toml").unwrap(); + v2.with_version("2.0.0".parse().unwrap()) + .get("added", |_, _| async move { Ok("added") }.boxed()) + .unwrap(); + + app.register_module("api", v1) + .unwrap() + .register_module("api", v2) + .unwrap(); + app.serve(format!("0.0.0.0:{}", port), STATIC_VER).await +} + +#[async_std::main] +async fn main() -> io::Result<()> { + // Configure logs with timestamps and settings from the RUST_LOG environment variable. + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .try_init() + .unwrap(); + serve(8080).await +} + +#[cfg(test)] +mod test { + use super::*; + use async_std::task::spawn; + use portpicker::pick_unused_port; + use tide_disco::{ + testing::{setup_test, Client}, + StatusCode, Url, + }; + + #[async_std::test] + async fn smoketest() { + // There are thorough tests for versioning and all the edge cases of version-aware routing + // in app.rs. This test is just a basic smoketest to prevent us from shipping a broken + // example. + setup_test(); + + let port = pick_unused_port().unwrap(); + spawn(serve(port)); + let url = Url::parse(&format!("http://localhost:{}/", port)).unwrap(); + let client = Client::new(url).await; + + assert_eq!( + "deleted", + client + .get("v1/api/deleted") + .send() + .await + .unwrap() + .json::() + .await + .unwrap() + ); + assert_eq!( + StatusCode::NotFound, + client.get("v1/api/added").send().await.unwrap().status() + ); + + assert_eq!( + "added", + client + .get("v2/api/added") + .send() + .await + .unwrap() + .json::() + .await + .unwrap() + ); + assert_eq!( + StatusCode::NotFound, + client.get("v2/api/deleted").send().await.unwrap().status() + ); + + assert_eq!( + "added", + client + .get("api/added") + .send() + .await + .unwrap() + .json::() + .await + .unwrap() + ); + assert_eq!( + StatusCode::NotFound, + client.get("api/deleted").send().await.unwrap().status() + ); + } +} diff --git a/examples/versions/v1.toml b/examples/versions/v1.toml new file mode 100644 index 00000000..bb0111c4 --- /dev/null +++ b/examples/versions/v1.toml @@ -0,0 +1,16 @@ +[meta] +FORMAT_VERSION = "0.1.0" +NAME = "versions" +DESCRIPTION = """ +An example of API versioning. + +This file specifies v1 of an app which has since been updated to v2, removing the route `deleted` +and changing some metadata. The associated tide-disco app serves both versions of the API +simultaneously, under version prefixes. +""" + +[route.deleted] +PATH = ["deleted"] +DOC = """ +This route will be deleted in version 2. +""" diff --git a/examples/versions/v2.toml b/examples/versions/v2.toml new file mode 100644 index 00000000..75d0a573 --- /dev/null +++ b/examples/versions/v2.toml @@ -0,0 +1,16 @@ +[meta] +FORMAT_VERSION = "0.1.0" +NAME = "versions" +DESCRIPTION = """ +An example of API versioning. + +This file specifies v2 of an app which has been updated from v1, removing the route `deleted` and +changing some metadata. The associated tide-disco app serves both versions of the API +simultaneously, under version prefixes. +""" + +[route.added] +PATH = ["added"] +DOC = """ +This route was added in version 2. +""" diff --git a/flake.nix b/flake.nix index 312ac49d..e5cceb3e 100644 --- a/flake.nix +++ b/flake.nix @@ -74,6 +74,12 @@ ''; in { devShell = pkgs.mkShell { + shellHook = '' + # Prevent cargo aliases from using programs in `~/.cargo` to avoid conflicts with rustup + # installations. + export CARGO_HOME=$HOME/.cargo-nix + ''; + buildInputs = with pkgs; [ fenix.packages.${system}.rust-analyzer @@ -82,9 +88,12 @@ rustToolchain ] ++ rustDeps; + # Use a distinct target dir for builds from within nix shells. + CARGO_TARGET_DIR = "target/nix"; RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library"; RUST_BACKTRACE = 1; RUST_LOG = "info"; + RUSTFLAGS = "--cfg async_executor_impl=\"async-std\" --cfg async_channel_impl=\"async-std\""; }; }); } diff --git a/src/api.rs b/src/api.rs index 5a317888..f947016a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -32,7 +32,7 @@ use tide::http::content::Accept; use versioned_binary_serialization::version::StaticVersionType; /// An error encountered when parsing or constructing an [Api]. -#[derive(Clone, Debug, Snafu)] +#[derive(Clone, Debug, Snafu, PartialEq, Eq)] pub enum ApiError { Route { source: RouteParseError }, ApiMustBeTable, @@ -430,26 +430,15 @@ impl Api { /// Set the API version. /// - /// The version information will automatically be included in responses to `GET /version`. + /// The version information will automatically be included in responses to `GET /version`. This + /// version can also be used to serve multiple major versions of the same API simultaneously, + /// under a version prefix. For more information, see + /// [App::register_module](crate::App::register_module). /// /// This is the version of the application or sub-application which this instance of [Api] - /// represents. The versioning encompasses both the API specification passed to [new](Api::new) - /// and the Rust crate implementing the route handlers for the API. Changes to either of - /// these components should result in a change to the version. - /// - /// Since the API specification and the route handlers are usually packaged together, and since - /// Rust crates are versioned anyways using Cargo, it is a good idea to use the version of the - /// API crate found in Cargo.toml. This can be automatically found at build time using the - /// environment variable `CARGO_PKG_VERSION` and the [env] macro. As long as the following code - /// is contained in the API crate, it should result in a reasonable version: - /// - /// ``` - /// # use versioned_binary_serialization::version::StaticVersion; - /// # type StaticVer01 = StaticVersion<0, 1>; - /// # fn ex(api: &mut tide_disco::Api<(), (), StaticVer01>) { - /// api.with_version(env!("CARGO_PKG_VERSION").parse().unwrap()); - /// # } - /// ``` + /// represents. The versioning corresponds to the API specification passed to [new](Api::new), + /// and may be different from the version of the Rust crate implementing the route handlers for + /// the API. pub fn with_version(&mut self, version: Version) -> &mut Self { self.api_version = Some(version); self @@ -1333,15 +1322,12 @@ mod test { error::{Error, ServerError}, healthcheck::HealthStatus, socket::Connection, - wait_for_server, App, StatusCode, Url, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS, + testing::{setup_test, test_ws_client, test_ws_client_with_headers, Client}, + App, StatusCode, Url, }; use async_std::{sync::RwLock, task::spawn}; use async_tungstenite::{ - async_std::connect_async, - tungstenite::{ - client::IntoClientRequest, http::header::*, protocol::frame::coding::CloseCode, - protocol::Message, - }, + tungstenite::{http::header::*, protocol::frame::coding::CloseCode, protocol::Message}, WebSocketStream, }; use futures::{ @@ -1386,6 +1372,8 @@ mod test { #[async_std::test] async fn test_socket_endpoint() { + setup_test(); + let mut app = App::<_, ServerError, StaticVer01>::with_state(RwLock::new(())); let api_toml = toml! { [meta] @@ -1446,17 +1434,13 @@ mod test { let port = pick_unused_port().unwrap(); let url: Url = format!("http://localhost:{}", port).parse().unwrap(); spawn(app.serve(format!("0.0.0.0:{}", port), VER_0_1)); - wait_for_server(&url, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS).await; - - let mut socket_url = url.join("mod/echo").unwrap(); - socket_url.set_scheme("ws").unwrap(); // Create a client that accepts JSON messages. - let mut socket_req = socket_url.clone().into_client_request().unwrap(); - socket_req - .headers_mut() - .insert(ACCEPT, "application/json".parse().unwrap()); - let mut conn = connect_async(socket_req).await.unwrap().0; + let mut conn = test_ws_client_with_headers( + url.join("mod/echo").unwrap(), + &[(ACCEPT, "application/json")], + ) + .await; // Send a JSON message. conn.send(Message::Text(serde_json::to_string("hello").unwrap())) @@ -1479,11 +1463,11 @@ mod test { ); // Create a client that accepts binary messages. - let mut socket_req = socket_url.into_client_request().unwrap(); - socket_req - .headers_mut() - .insert(ACCEPT, "application/octet-stream".parse().unwrap()); - let mut conn = connect_async(socket_req).await.unwrap().0; + let mut conn = test_ws_client_with_headers( + url.join("mod/echo").unwrap(), + &[(ACCEPT, "application/octet-stream")], + ) + .await; // Send a JSON message. conn.send(Message::Text(serde_json::to_string("hello").unwrap())) @@ -1506,9 +1490,7 @@ mod test { ); // Test a stream that exits normally. - let mut socket_url = url.join("mod/once").unwrap(); - socket_url.set_scheme("ws").unwrap(); - let mut conn = connect_async(socket_url).await.unwrap().0; + let mut conn = test_ws_client(url.join("mod/once").unwrap()).await; assert_eq!( conn.next().await.unwrap().unwrap(), Message::Text(serde_json::to_string("msg").unwrap()) @@ -1520,9 +1502,7 @@ mod test { check_stream_closed(conn).await; // Test a stream that errors. - let mut socket_url = url.join("mod/error").unwrap(); - socket_url.set_scheme("ws").unwrap(); - let mut conn = connect_async(socket_url).await.unwrap().0; + let mut conn = test_ws_client(url.join("mod/error").unwrap()).await; match conn.next().await.unwrap().unwrap() { Message::Close(Some(frame)) => { assert_eq!(frame.code, CloseCode::Error); @@ -1535,6 +1515,8 @@ mod test { #[async_std::test] async fn test_stream_endpoint() { + setup_test(); + let mut app = App::<_, ServerError, StaticVer01>::with_state(RwLock::new(())); let api_toml = toml! { [meta] @@ -1572,13 +1554,9 @@ mod test { let port = pick_unused_port().unwrap(); let url: Url = format!("http://localhost:{}", port).parse().unwrap(); spawn(app.serve(format!("0.0.0.0:{}", port), VER_0_1)); - wait_for_server(&url, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS).await; // Consume the `nat` stream. - let mut socket_url = url.join("mod/nat").unwrap(); - socket_url.set_scheme("ws").unwrap(); - let mut conn = connect_async(socket_url).await.unwrap().0; - + let mut conn = test_ws_client(url.join("mod/nat").unwrap()).await; for i in 0..100 { assert_eq!( conn.next().await.unwrap().unwrap(), @@ -1587,10 +1565,7 @@ mod test { } // Test a finite stream. - let mut socket_url = url.join("mod/once").unwrap(); - socket_url.set_scheme("ws").unwrap(); - let mut conn = connect_async(socket_url).await.unwrap().0; - + let mut conn = test_ws_client(url.join("mod/once").unwrap()).await; assert_eq!( conn.next().await.unwrap().unwrap(), Message::Text(serde_json::to_string(&0).unwrap()) @@ -1602,10 +1577,7 @@ mod test { check_stream_closed(conn).await; // Test a stream that errors. - let mut socket_url = url.join("mod/error").unwrap(); - socket_url.set_scheme("ws").unwrap(); - let mut conn = connect_async(socket_url).await.unwrap().0; - + let mut conn = test_ws_client(url.join("mod/error").unwrap()).await; match conn.next().await.unwrap().unwrap() { Message::Close(Some(frame)) => { assert_eq!(frame.code, CloseCode::Error); @@ -1618,6 +1590,8 @@ mod test { #[async_std::test] async fn test_custom_healthcheck() { + setup_test(); + let mut app = App::<_, ServerError, StaticVer01>::with_state(HealthStatus::Available); let api_toml = toml! { [meta] @@ -1633,21 +1607,20 @@ mod test { let port = pick_unused_port().unwrap(); let url: Url = format!("http://localhost:{}", port).parse().unwrap(); spawn(app.serve(format!("0.0.0.0:{}", port), VER_0_1)); - wait_for_server(&url, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS).await; + let client = Client::new(url).await; - let mut res = surf::get(format!("http://localhost:{}/mod/healthcheck", port)) - .send() - .await - .unwrap(); + let res = client.get("/mod/healthcheck").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); assert_eq!( - res.body_json::().await.unwrap(), + res.json::().await.unwrap(), HealthStatus::Available ); } #[async_std::test] async fn test_metrics_endpoint() { + setup_test(); + struct State { metrics: Registry, counter: Counter, @@ -1685,13 +1658,14 @@ mod test { let port = pick_unused_port().unwrap(); let url: Url = format!("http://localhost:{port}").parse().unwrap(); spawn(app.serve(format!("0.0.0.0:{port}"), VER_0_1)); - wait_for_server(&url, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS).await; + let client = Client::new(url).await; for i in 1..5 { + tracing::info!("making metrics request {i}"); let expected = format!("# HELP counter count of how many times metrics have been exported\n# TYPE counter counter\ncounter {i}\n"); - let mut res = surf::get(format!("{url}mod/metrics")).send().await.unwrap(); - assert_eq!(res.body_string().await.unwrap(), expected); + let res = client.get("mod/metrics").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); + assert_eq!(res.text().await.unwrap(), expected); } } } diff --git a/src/app.rs b/src/app.rs index 3ac33a83..cc1e9f3a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,19 +17,25 @@ use crate::{ use async_std::sync::Arc; use futures::future::{BoxFuture, FutureExt}; use include_dir::{include_dir, Dir}; +use itertools::Itertools; use lazy_static::lazy_static; use maud::{html, PreEscaped}; use semver::Version; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DisplayFromStr}; use snafu::{ResultExt, Snafu}; -use std::collections::hash_map::{Entry, HashMap}; -use std::convert::Infallible; -use std::env; -use std::fs; -use std::io; -use std::ops::{Deref, DerefMut}; -use std::path::PathBuf; +use std::{ + collections::{ + btree_map::{BTreeMap, Entry as BTreeEntry}, + hash_map::{Entry as HashEntry, HashMap}, + }, + convert::Infallible, + env, + fmt::Display, + fs, io, + ops::{Deref, DerefMut}, + path::PathBuf, +}; use tide::{ http::{headers::HeaderValue, mime}, security::{CorsMiddleware, Origin}, @@ -45,15 +51,16 @@ pub use tide::listener::{Listener, ToListener}; /// constructing an [Api] for each module and calling [App::register_module]. Once all of the /// desired modules are registered, the app can be converted into an asynchronous server task using /// [App::serve]. +#[derive(Debug)] pub struct App { - // Map from base URL to module API. - apis: HashMap>, + // Map from base URL, major version to API. + apis: HashMap>>, state: Arc, app_version: Option, } /// An error encountered while building an [App]. -#[derive(Clone, Debug, Snafu)] +#[derive(Clone, Debug, Snafu, PartialEq, Eq)] pub enum AppError { Api { source: ApiError }, ModuleAlreadyExists, @@ -75,6 +82,12 @@ impl< } /// Create and register an API module. + /// + /// Creates a new [`Api`] with the given `api` specification and returns an RAII guard for this + /// API. The guard can be used to access the API module, configure it, and populate its + /// handlers. When [`Module::register`] is called on the guard (or the guard is dropped), the + /// module will be registered in this [`App`] as if by calling + /// [`register_module`](Self::register_module). pub fn module<'a, ModuleError>( &'a mut self, base_url: &'a str, @@ -84,10 +97,6 @@ impl< Error: From, ModuleError: 'static + Send + Sync, { - if self.apis.contains_key(base_url) { - return Err(AppError::ModuleAlreadyExists); - } - Ok(Module { app: self, base_url, @@ -96,6 +105,37 @@ impl< } /// Register an API module. + /// + /// The module `api` will be registered as an implementation of the module hosted under the URL + /// prefix `base_url`. + /// + /// # Versioning + /// + /// Multiple versions of the same [`Api`] may be registered by calling this function several + /// times with the same `base_url`, and passing in different APIs which must have different + /// _major_ versions. The API version can be set using [`Api::with_version`]. + /// + /// When multiple versions of the same API are registered, requests for endpoints directly under + /// the base URL, like `GET /base_url/endpoint`, will always be dispatched to the latest + /// available version of the API. There will in addition be an extension of `base_url` for each + /// major version registered, so `GET /base_url/v1/endpoint` will always dispatch to the + /// `endpoint` handler in the module with major version 1, if it exists, regardless of what the + /// latest version is. + /// + /// It is an error to register multiple versions of the same module with the same major version. + /// It is _not_ an error to register non-sequential versions of a module. For example, you could + /// have `/base_url/v2` and `/base_url/v4`, but not `v1` or `v3`. Requests for `v1` or `v3` will + /// simply fail. + /// + /// The intention of this functionality is to allow for non-disruptive breaking updates. Rather + /// than deploying a new major version of the API with breaking changes _in place of_ the old + /// version, breaking all your clients, you can continue to serve the old version for some + /// period of time under a version prefix. Clients can point at this version prefix until they + /// update their software to use the new version, on their own time. + /// + /// Note that non-breaking changes (e.g. new endpoints) can be deployed in place of an existing + /// API without even incrementing the major version. The need for serving two versions of an API + /// simultaneously only arises when you have breaking changes. pub fn register_module( &mut self, base_url: &str, @@ -105,14 +145,28 @@ impl< Error: From, ModuleError: 'static + Send + Sync, { - match self.apis.entry(base_url.to_string()) { - Entry::Occupied(_) => { - return Err(AppError::ModuleAlreadyExists); + let mut api = api.map_err(Error::from); + api.set_name(base_url.to_string()); + + let major_version = match api.version().api_version { + Some(version) => version.major, + None => { + // If no version is explicitly specified, default to 0. + 0 } - Entry::Vacant(e) => { - let mut api = api.map_err(Error::from); - api.set_name(base_url.to_string()); - e.insert(api); + }; + + match self.apis.entry(base_url.to_string()) { + HashEntry::Occupied(mut e) => match e.get_mut().entry(major_version) { + BTreeEntry::Occupied(_) => { + return Err(AppError::ModuleAlreadyExists); + } + BTreeEntry::Vacant(e) => { + e.insert(api); + } + }, + HashEntry::Vacant(e) => { + e.insert([(major_version, api)].into()); } } @@ -158,7 +212,12 @@ impl< modules: self .apis .iter() - .map(|(name, api)| (name.clone(), api.version())) + .map(|(name, versions)| { + ( + name.clone(), + versions.values().rev().map(|api| api.version()).collect(), + ) + }) .collect(), } } @@ -170,14 +229,17 @@ impl< /// (due to type erasure) but can be queried using [module_health](Self::module_health) or by /// hitting the endpoint `GET /:module/healthcheck`. pub async fn health(&self, req: RequestParams, state: &State) -> AppHealth { - let mut modules = HashMap::new(); + let mut modules = BTreeMap::>::new(); let mut status = HealthStatus::Available; - for (name, api) in &self.apis { - let health = StatusCode::from(api.health(req.clone(), state).await.status()); - if health != StatusCode::Ok { - status = HealthStatus::Unhealthy; + for (name, versions) in &self.apis { + let module = modules.entry(name.clone()).or_default(); + for (version, api) in versions { + let health = StatusCode::from(api.health(req.clone(), state).await.status()); + if health != StatusCode::Ok { + status = HealthStatus::Unhealthy; + } + module.insert(*version, health); } - modules.insert(name.clone(), health); } AppHealth { status, modules } } @@ -189,14 +251,22 @@ impl< /// registered healthcheck handler. If the module does not have an explicit healthcheck /// handler, the response will be a [HealthStatus]. /// - /// If there is no module with the given name, returns [None]. + /// `major_version` can be used to query the health status of a specific version of the desired + /// module. If it is not provided, the most recent supported version will be queried. + /// + /// If there is no module with the given name or version, returns [None]. pub async fn module_health( &self, req: RequestParams, state: &State, module: &str, + major_version: Option, ) -> Option { - let api = self.apis.get(module)?; + let versions = self.apis.get(module)?; + let api = match major_version { + Some(v) => versions.get(&v)?, + None => versions.last_key_value()?.1, + }; Some(api.health(req, state).await) } } @@ -231,17 +301,7 @@ impl< ) -> io::Result<()> { let state = Arc::new(self); let mut server = tide::Server::with_state(state.clone()); - for (name, api) in &state.apis { - // Clippy complains if the only non-trivial operation in an `unwrap_or_else` closure is - // a deref, but for `lazy_static` types, deref is an effectful operation that (in this - // case) causes a directory to be renamed and another extracted. We only want to execute - // this if we need to (if `api.public()` is `None`) so we disable the lint. - #[allow(clippy::unnecessary_lazy_evaluations)] - server - .at("/public") - .at(name) - .serve_dir(api.public().unwrap_or_else(|| &DEFAULT_PUBLIC_PATH))?; - } + server.with(Self::version_middleware); server.with(add_error_body::<_, Error, VER>); server.with( CorsMiddleware::new() @@ -251,87 +311,8 @@ impl< .allow_credentials(true), ); - for (prefix, api) in &state.apis { - // Register routes for this API. - let mut api_endpoint = server.at(prefix); - for (path, routes) in api.routes_by_path() { - let mut endpoint = api_endpoint.at(path); - let routes = routes.collect::>(); - - // Register socket and metrics middlewares. These must be registered before any - // regular HTTP routes, because Tide only applies middlewares to routes which were - // already registered before the route handler. - if let Some(socket_route) = - routes.iter().find(|route| route.method() == Method::Socket) - { - // If there is a socket route with this pattern, add the socket middleware to - // all endpoints registered under this pattern, so that any request with any - // method that has the socket upgrade headers will trigger a WebSockets upgrade. - Self::register_socket(prefix.to_owned(), &mut endpoint, socket_route); - } - if let Some(metrics_route) = routes - .iter() - .find(|route| route.method() == Method::Metrics) - { - // If there is a metrics route with this pattern, add the metrics middleware to - // all endpoints registered under this pattern, so that a request to this path - // with the right headers will return metrics instead of going through the - // normal method-based dispatching. - Self::register_metrics( - prefix.to_owned(), - &mut endpoint, - metrics_route, - bind_version, - ); - } - - // Register the HTTP routes. - for route in routes { - if let Method::Http(method) = route.method() { - Self::register_route( - prefix.to_owned(), - &mut endpoint, - route, - method, - bind_version, - ); - } - } - } - - // Register automatic routes for this API: `healthcheck` and `version`. - { - let prefix = prefix.clone(); - server - .at(&prefix) - .at("healthcheck") - .get(move |req: tide::Request>| { - let prefix = prefix.clone(); - async move { - let api = &req.state().clone().apis[&prefix]; - let state = req.state().clone(); - Ok(api - .health(request_params(req, &[]).await?, &state.state) - .await) - } - }); - } - { - let prefix = prefix.clone(); - server - .at(&prefix) - .at("version") - .get(move |req: tide::Request>| { - let prefix = prefix.clone(); - async move { - let api = &req.state().apis[&prefix]; - let accept = RequestParams::accept_from_headers(&req)?; - respond_with(&accept, api.version(), bind_version).map_err(|err| { - Error::from_route_error::(err).into_tide_error() - }) - } - }); - } + for (name, versions) in &state.apis { + Self::register_api(&mut server, name.clone(), versions, bind_version)?; } // Register app-level automatic routes: `healthcheck` and `version`. @@ -353,34 +334,12 @@ impl< .map_err(|err| Error::from_route_error::(err).into_tide_error()) }); - // Register catch-all routes for discoverability - { - server - .at("/") - .all(move |req: tide::Request>| async move { - Ok(html! { - "This is a Tide Disco app composed of the following modules:" - (req.state().list_apis()) - }) - }); - } - { - server - .at("/*") - .all(move |req: tide::Request>| async move { - let api_name = req.url().path_segments().unwrap().next().unwrap(); - let state = req.state(); - if let Some(api) = state.apis.get(api_name) { - Ok(api.documentation()) - } else { - Ok(html! { - "No valid route begins with \"" (api_name) "\". Try routes beginning - with one of the following API identifiers:" - (state.list_apis()) - }) - } - }); - } + // Serve documentation at the root URL for discoverability + server + .at("/") + .all(move |req: tide::Request>| async move { + Ok(tide::Response::from(Self::top_level_docs(req))) + }); server.listen(listener).await } @@ -388,19 +347,177 @@ impl< fn list_apis(&self) -> Html { html! { ul { - @for (name, api) in &self.apis { + @for (name, versions) in &self.apis { li { + // Link to the alias for the latest version as the primary link. a href=(format!("/{}", name)) {(name)} + // Add a superscript link (link a footnote) for each specific supported + // version, linking to documentation for that specific version. + @for version in versions.keys().rev() { + sup { + a href=(format!("/v{version}/{name}")) { + (format!("[v{version}]")) + } + } + } " " - (PreEscaped(api.short_description())) + // Take the description of the latest supported version. + (PreEscaped(versions.last_key_value().unwrap().1.short_description())) } } } } } + fn register_api( + server: &mut tide::Server>, + prefix: String, + versions: &BTreeMap>, + bind_version: VER, + ) -> io::Result<()> { + for (version, api) in versions { + Self::register_api_version(server, &prefix, *version, api, bind_version)?; + } + Ok(()) + } + + fn register_api_version( + server: &mut tide::Server>, + prefix: &String, + version: u64, + api: &Api, + bind_version: VER, + ) -> io::Result<()> { + // Clippy complains if the only non-trivial operation in an `unwrap_or_else` closure is + // a deref, but for `lazy_static` types, deref is an effectful operation that (in this + // case) causes a directory to be renamed and another extracted. We only want to execute + // this if we need to (if `api.public()` is `None`) so we disable the lint. + #[allow(clippy::unnecessary_lazy_evaluations)] + server + .at("/public") + .at(&format!("v{version}")) + .at(prefix) + .serve_dir(api.public().unwrap_or_else(|| &DEFAULT_PUBLIC_PATH))?; + + // Register routes for this API. + let mut api_endpoint = server.at(&format!("/v{version}/{prefix}")); + for (path, routes) in api.routes_by_path() { + let mut endpoint = api_endpoint.at(path); + let routes = routes.collect::>(); + + // Register socket and metrics middlewares. These must be registered before any + // regular HTTP routes, because Tide only applies middlewares to routes which were + // already registered before the route handler. + if let Some(socket_route) = routes.iter().find(|route| route.method() == Method::Socket) + { + // If there is a socket route with this pattern, add the socket middleware to + // all endpoints registered under this pattern, so that any request with any + // method that has the socket upgrade headers will trigger a WebSockets upgrade. + Self::register_socket(prefix.to_owned(), version, &mut endpoint, socket_route); + } + if let Some(metrics_route) = routes + .iter() + .find(|route| route.method() == Method::Metrics) + { + // If there is a metrics route with this pattern, add the metrics middleware to + // all endpoints registered under this pattern, so that a request to this path + // with the right headers will return metrics instead of going through the + // normal method-based dispatching. + Self::register_metrics( + prefix.to_owned(), + version, + &mut endpoint, + metrics_route, + bind_version, + ); + } + + // Register the HTTP routes. + for route in routes { + if let Method::Http(method) = route.method() { + Self::register_route( + prefix.to_owned(), + version, + &mut endpoint, + route, + method, + bind_version, + ); + } + } + } + + // Register automatic routes for this API: documentation, `healthcheck` and `version`. Serve + // documentation at the root of the API (with or without a trailing slash). + for path in ["", "/"] { + let prefix = prefix.clone(); + api_endpoint + .at(path) + .all(move |req: tide::Request>| { + let prefix = prefix.clone(); + async move { + let api = &req.state().clone().apis[&prefix][&version]; + Ok(api.documentation()) + } + }); + } + { + let prefix = prefix.clone(); + api_endpoint + .at("*path") + .all(move |req: tide::Request>| { + let prefix = prefix.clone(); + async move { + // The request did not match any route. Serve documentation for the API. + let api = &req.state().clone().apis[&prefix][&version]; + let docs = html! { + "No route matches /" (req.param("path")?) + br{} + (api.documentation()) + }; + Ok(tide::Response::builder(StatusCode::NotFound) + .body(docs.into_string()) + .build()) + } + }); + } + { + let prefix = prefix.clone(); + api_endpoint + .at("healthcheck") + .get(move |req: tide::Request>| { + let prefix = prefix.clone(); + async move { + let api = &req.state().clone().apis[&prefix][&version]; + let state = req.state().clone(); + Ok(api + .health(request_params(req, &[]).await?, &state.state) + .await) + } + }); + } + { + let prefix = prefix.clone(); + api_endpoint + .at("version") + .get(move |req: tide::Request>| { + let prefix = prefix.clone(); + async move { + let api = &req.state().apis[&prefix][&version]; + let accept = RequestParams::accept_from_headers(&req)?; + respond_with(&accept, api.version(), bind_version).map_err(|err| { + Error::from_route_error::(err).into_tide_error() + }) + } + }); + } + + Ok(()) + } + fn register_route( api: String, + version: u64, endpoint: &mut tide::Route>, route: &Route, method: http::Method, @@ -411,7 +528,7 @@ impl< let name = name.clone(); let api = api.clone(); async move { - let route = &req.state().clone().apis[&api][&name]; + let route = &req.state().clone().apis[&api][&version][&name]; let state = &*req.state().clone().state; let req = request_params(req, route.params()).await?; route @@ -428,6 +545,7 @@ impl< fn register_metrics( api: String, + version: u64, endpoint: &mut tide::Route>, route: &Route, bind_version: VER, @@ -440,6 +558,7 @@ impl< endpoint.with(MetricsMiddleware::new( name.clone(), api.clone(), + version, bind_version, )); } @@ -453,11 +572,12 @@ impl< // We register the default handler using `all`, which makes it act as a fallback handler. // This means if there are other, non-metrics routes with this same path, we will still // dispatch to them if the path is hit with the appropriate method. - Self::register_fallback(api, endpoint, route); + Self::register_fallback(api, version, endpoint, route); } fn register_socket( api: String, + version: u64, endpoint: &mut tide::Route>, route: &Route, ) { @@ -472,7 +592,7 @@ impl< let name = name.clone(); let api = api.clone(); async move { - let route = &req.state().clone().apis[&api][&name]; + let route = &req.state().clone().apis[&api][&version][&name]; let state = &*req.state().clone().state; let req = request_params(req, route.params()).await?; route @@ -500,11 +620,12 @@ impl< // We register the default handler using `all`, which makes it act as a fallback handler. // This means if there are other, non-socket routes with this same path, we will still // dispatch to them if the path is hit with the appropriate method. - Self::register_fallback(api, endpoint, route); + Self::register_fallback(api, version, endpoint, route); } fn register_fallback( api: String, + version: u64, endpoint: &mut tide::Route>, route: &Route, ) { @@ -513,7 +634,7 @@ impl< let name = name.clone(); let api = api.clone(); async move { - let route = &req.state().clone().apis[&api][&name]; + let route = &req.state().clone().apis[&api][&version][&name]; route .default_handler() .map_err(|err| match err { @@ -524,17 +645,124 @@ impl< } }); } + + /// Server middleware which returns redirect responses for requests lacking an explicit version + /// prefix. + fn version_middleware( + req: tide::Request>, + next: tide::Next>, + ) -> BoxFuture { + async move { + let Some(mut path) = req.url().path_segments() else { + // If we can't parse the path, we can't run this middleware. Do our best by + // continuing the request processing lifecycle. + return Ok(next.run(req).await); + }; + let Some(seg1) = path.next() else { + // This is the root URL, with no path segments. Nothing for this middleware to do. + return Ok(next.run(req).await); + }; + if seg1.is_empty() { + // This is the root URL, with no path segments. Nothing for this middleware to do. + return Ok(next.run(req).await); + } + + // The first segment is either a version identifier or an API identifier (implicitly + // requesting the latest version of the API). We handle these cases differently. + if let Some(version) = seg1.strip_prefix('v').and_then(|n| n.parse().ok()) { + // If the version identifier is present, we probably don't need a redirect. However, + // we still check if this is a valid version for the request API. If not, we will + // serve documentation listing the available versions. + let Some(api) = path.next() else { + // A version identifier with no API is an error, serve documentation. + return Ok(Self::top_level_error( + req, + StatusCode::BadRequest, + "illegal version prefix without API specifier", + )); + }; + let Some(versions) = req.state().apis.get(api) else { + let message = format!("No API matches /{api}"); + return Ok(Self::top_level_error(req, StatusCode::NotFound, message)); + }; + if versions.get(&version).is_none() { + // This version is not supported, list suported versions. + return Ok(html! { + "Unsupported version v" (version) ". Supported versions are:" + ul { + @for v in versions.keys().rev() { + li { + a href=(format!("/v{v}/{api}")) { "v" (v) } + } + } + } + } + .into()); + } + + // This is a valid request with a specific version. It should be handled + // successfully by the route handlers for this API. + Ok(next.run(req).await) + } else { + // If the first path segment is not a version prefix, it is either the name of an + // API or one of the magic top-level endpoints (version, healthcheck), implicitly + // requesting the latest version. Validate the API and then redirect. + if ["version", "healthcheck"].contains(&seg1) { + return Ok(next.run(req).await); + } + let Some(versions) = req.state().apis.get(seg1) else { + let message = format!("No API matches /{seg1}"); + return Ok(Self::top_level_error(req, StatusCode::NotFound, message)); + }; + + let latest_version = *versions.last_key_value().unwrap().0; + let path = path.join("/"); + Ok(tide::Redirect::permanent(format!("/v{latest_version}/{seg1}/{path}")).into()) + } + } + .boxed() + } + + /// Top-level documentation about the app. + fn top_level_docs(req: tide::Request>) -> PreEscaped { + html! { + br {} + "This is a Tide Disco app composed of the following modules:" + (req.state().list_apis()) + } + } + + /// Documentation served when there is a routing error at the app level. + fn top_level_error( + req: tide::Request>, + status: StatusCode, + message: impl Display, + ) -> tide::Response { + let docs = html! { + (message.to_string()) + (Self::top_level_docs(req)) + }; + tide::Response::builder(status) + .body(docs.into_string()) + .build() + } } struct MetricsMiddleware { route: String, api: String, + api_version: u64, ver: VER, } impl MetricsMiddleware { - fn new(route: String, api: String, ver: VER) -> Self { - Self { route, api, ver } + fn new(route: String, api: String, api_version: u64, ver: VER) -> Self { + Self { + route, + api, + api_version, + ver, + } } } @@ -556,6 +784,7 @@ where { let route = self.route.clone(); let api = self.api.clone(); + let version = self.api_version; let bind_version = self.ver; async move { if req.method() != http::Method::Get { @@ -574,7 +803,7 @@ where } // This is a metrics request, abort the rest of the dispatching chain and run the // metrics handler. - let route = &req.state().clone().apis[&api][&route]; + let route = &req.state().clone().apis[&api][&version][&route]; let state = &*req.state().clone().state; let req = request_params(req, route.params()).await?; route @@ -607,8 +836,8 @@ pub struct AppHealth { /// [HealthStatus::Available] if all of the application's modules are healthy, otherwise a /// [HealthStatus] variant with [status](HealthCheck::status) other than 200. pub status: HealthStatus, - /// The status of each registered module. - pub modules: HashMap, + /// The status of each registered module, indexed by version. + pub modules: BTreeMap>, } impl HealthCheck for AppHealth { @@ -621,8 +850,10 @@ impl HealthCheck for AppHealth { #[serde_as] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct AppVersion { - /// The version of each module registered with this application. - pub modules: HashMap, + /// The supported versions of each module registered with this application. + /// + /// Versions for each module are ordered from newest to oldest. + pub modules: BTreeMap>, /// The version of this application. #[serde_as(as = "Option")] @@ -670,6 +901,17 @@ fn add_error_body< }) } +/// RAII guard to ensure a module is registered after it is configured. +/// +/// This type allows the owner to configure an [`Api`] module via the [`Deref`] and [`DerefMut`] +/// traits. Once the API is configured, this object can be dropped, which will automatically +/// register the module with the [`App`]. +/// +/// # Panics +/// +/// Note that if anything goes wrong during module registration (for example, there is already an +/// incompatible module registered with the same name), the drop implementation may panic. To handle +/// errors without panicking, call [`register`](Self::register) explicitly. pub struct Module<'a, State, Error, ModuleError, VER: StaticVersionType> where State: 'static + Send + Sync, @@ -720,9 +962,32 @@ where VER: 'static + Send + Sync, { fn drop(&mut self) { - self.app - .register_module(self.base_url, self.api.take().unwrap()) - .unwrap(); + self.register_impl().unwrap(); + } +} + +impl<'a, State, Error, ModuleError, VER> Module<'a, State, Error, ModuleError, VER> +where + State: 'static + Send + Sync, + Error: 'static + From, + ModuleError: 'static + Send + Sync, + VER: 'static + Send + Sync + StaticVersionType, +{ + /// Register this module with the linked app. + pub fn register(mut self) -> Result<(), AppError> { + self.register_impl() + } + + /// Perform the logic of [`Self::register`] without consuming `self`, so this can be called from + /// `drop`. + fn register_impl(&mut self) -> Result<(), AppError> { + if let Some(api) = self.api.take() { + self.app.register_module(self.base_url, api)?; + Ok(()) + } else { + // Already registered. + Ok(()) + } } } @@ -730,11 +995,14 @@ where mod test { use super::*; use crate::{ - error::ServerError, metrics::Metrics, socket::Connection, wait_for_server, Url, - SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS, + error::ServerError, + metrics::Metrics, + socket::Connection, + testing::{setup_test, test_ws_client, Client}, + Url, }; use async_std::{sync::RwLock, task::spawn}; - use async_tungstenite::{async_std::connect_async, tungstenite::Message}; + use async_tungstenite::tungstenite::Message; use futures::{FutureExt, SinkExt, StreamExt}; use portpicker::pick_unused_port; use std::borrow::Cow; @@ -759,6 +1027,8 @@ mod test { /// Test route dispatching for routes with the same path and different methods. #[async_std::test] async fn test_method_dispatch() { + setup_test(); + use crate::http::Method::*; let mut app = App::<_, ServerError, StaticVer01>::with_state(RwLock::new(FakeMetrics)); @@ -827,48 +1097,37 @@ mod test { let port = pick_unused_port().unwrap(); let url: Url = format!("http://localhost:{}", port).parse().unwrap(); spawn(app.serve(format!("0.0.0.0:{}", port), VER_0_1)); - wait_for_server(&url, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS).await; - - let client: surf::Client = surf::Config::new() - .set_base_url(url.clone()) - .try_into() - .unwrap(); + let client = Client::new(url.clone()).await; // Regular HTTP methods. for method in [Get, Post, Put, Delete] { - let mut res = client - .request(method, url.join("mod/test").unwrap()) + let res = client + .request(method, "mod/test") .header("Accept", "application/json") .send() .await .unwrap(); assert_eq!(res.status(), StatusCode::Ok); - assert_eq!(res.body_json::().await.unwrap(), method.to_string()); + assert_eq!(res.json::().await.unwrap(), method.to_string()); } // Metrics with Accept header. - let mut res = client - .get(url.join("mod/test").unwrap()) + let res = client + .get("mod/test") .header("Accept", "text/plain") .send() .await .unwrap(); - assert_eq!(res.body_string().await.unwrap(), "METRICS"); assert_eq!(res.status(), StatusCode::Ok); + assert_eq!(res.text().await.unwrap(), "METRICS"); // Metrics without Accept header. - let mut res = client - .get(url.join("mod/test").unwrap()) - .send() - .await - .unwrap(); - assert_eq!(res.body_string().await.unwrap(), "METRICS"); + let res = client.get("mod/test").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); + assert_eq!(res.text().await.unwrap(), "METRICS"); // Socket. - let mut socket_url = url.join("mod/test").unwrap(); - socket_url.set_scheme("ws").unwrap(); - let mut conn = connect_async(socket_url).await.unwrap().0; + let mut conn = test_ws_client(url.join("mod/test").unwrap()).await; let msg = conn.next().await.unwrap().unwrap(); let body: String = match msg { Message::Text(m) => serde_json::from_str(&m).unwrap(), @@ -881,6 +1140,8 @@ mod test { /// Test route dispatching for routes with patterns containing different parmaeters #[async_std::test] async fn test_param_dispatch() { + setup_test(); + let mut app = App::<_, ServerError, StaticVer01>::with_state(RwLock::new(())); let api_toml = toml! { [meta] @@ -908,33 +1169,401 @@ mod test { let port = pick_unused_port().unwrap(); let url: Url = format!("http://localhost:{}", port).parse().unwrap(); spawn(app.serve(format!("0.0.0.0:{}", port), VER_0_1)); - wait_for_server(&url, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS).await; - - let client: surf::Client = surf::Config::new() - .set_base_url(url.clone()) - .try_into() - .unwrap(); + let client = Client::new(url.clone()).await; - let mut res = client - .get(url.join("mod/test/a/42").unwrap()) - .send() - .await - .unwrap(); + let res = client.get("mod/test/a/42").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); assert_eq!( - res.body_json::<(String, String)>().await.unwrap(), + res.json::<(String, String)>().await.unwrap(), ("a".to_string(), "42".to_string()) ); - let mut res = client - .get(url.join("mod/test/b/true").unwrap()) - .send() - .await - .unwrap(); + let res = client.get("mod/test/b/true").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); assert_eq!( - res.body_json::<(String, String)>().await.unwrap(), + res.json::<(String, String)>().await.unwrap(), ("b".to_string(), "true".to_string()) ); } + + #[async_std::test] + async fn test_versions() { + setup_test(); + + let mut app = App::<_, ServerError, StaticVer01>::with_state(RwLock::new(())); + + // Create two different, non-consecutive major versions of an API. One method will be + // deleted in version 1, one will be added in version 3, and one will be present in both + // versions (with a different implementation). + let v1_toml = toml! { + [meta] + FORMAT_VERSION = "0.1.0" + + [route.deleted] + PATH = ["/deleted"] + + [route.unchanged] + PATH = ["/unchanged"] + }; + let v3_toml = toml! { + [meta] + FORMAT_VERSION = "0.1.0" + + [route.added] + PATH = ["/added"] + + [route.unchanged] + PATH = ["/unchanged"] + }; + + { + let mut v1 = app.module::("mod", v1_toml.clone()).unwrap(); + v1.with_version("1.0.0".parse().unwrap()) + .get("deleted", |_req, _state| { + async move { Ok("deleted v1") }.boxed() + }) + .unwrap() + .get("unchanged", |_req, _state| { + async move { Ok("unchanged v1") }.boxed() + }) + .unwrap() + // Add a custom healthcheck for the old version so we can check healthcheck routing. + .with_health_check(|_state| { + async move { HealthStatus::TemporarilyUnavailable }.boxed() + }); + } + { + // Registering the same major version twice is an error. + let mut api = app.module::("mod", v1_toml).unwrap(); + api.with_version("1.1.1".parse().unwrap()); + assert_eq!(api.register().unwrap_err(), AppError::ModuleAlreadyExists); + } + { + let mut v3 = app.module::("mod", v3_toml.clone()).unwrap(); + v3.with_version("3.0.0".parse().unwrap()) + .get("added", |_req, _state| { + async move { Ok("added v3") }.boxed() + }) + .unwrap() + .get("unchanged", |_req, _state| { + async move { Ok("unchanged v3") }.boxed() + }) + .unwrap(); + } + + let port = pick_unused_port().unwrap(); + let url: Url = format!("http://localhost:{}", port).parse().unwrap(); + spawn(app.serve(format!("0.0.0.0:{}", port), VER_0_1)); + let client = Client::new(url.clone()).await; + + // First check that we can call all the expected methods. + assert_eq!( + "deleted v1", + client + .get("v1/mod/deleted") + .send() + .await + .unwrap() + .json::() + .await + .unwrap() + ); + assert_eq!( + "unchanged v1", + client + .get("v1/mod/unchanged") + .send() + .await + .unwrap() + .json::() + .await + .unwrap() + ); + // For the v3 methods, we can query with or without a version prefix. + for prefix in ["", "/v3"] { + let span = tracing::info_span!("version", prefix); + let _enter = span.enter(); + + assert_eq!( + "added v3", + client + .get(&format!("{prefix}/mod/added")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap() + ); + assert_eq!( + "unchanged v3", + client + .get(&format!("{prefix}/mod/unchanged")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap() + ); + } + + // Test documentation for invalid routes. + let check_docs = |version, route: &'static str| { + let client = &client; + async move { + let span = tracing::info_span!("check_docs", ?version, route); + let _enter = span.enter(); + tracing::info!("test invalid route docs"); + + let prefix = match version { + Some(v) => format!("/v{v}"), + None => "".into(), + }; + + // Invalid route or no route with no version prefix redirects to documentation for + // the latest supported version. + let version = version.unwrap_or(3); + + let res = client + .get(&format!("{prefix}/mod/{route}")) + .send() + .await + .unwrap(); + let docs = res.text().await.unwrap(); + if !route.is_empty() { + assert!( + docs.contains(&format!("No route matches /{route}")), + "{docs}" + ); + } + assert!( + docs.contains(&format!("mod API {version}.0.0 Reference")), + "{docs}" + ); + } + }; + + for route in ["", "deleted"] { + check_docs(None, route).await; + } + for route in ["", "deleted"] { + check_docs(Some(3), route).await; + } + for route in ["", "added"] { + check_docs(Some(1), route).await; + } + + // Request with an unsupported version lists the supported versions. + let expected_html = html! { + "Unsupported version v2. Supported versions are:" + ul { + li { + a href="/v3/mod" {"v3"} + } + li { + a href="/v1/mod" {"v1"} + } + } + } + .into_string(); + for route in ["", "/unchanged"] { + let span = tracing::info_span!("unsupported_version_docs", route); + let _enter = span.enter(); + tracing::info!("test unsupported version docs"); + + let res = client.get(&format!("/v2/mod{route}")).send().await.unwrap(); + let docs = res.text().await.unwrap(); + assert_eq!(docs, expected_html); + } + + // Test version endpoints. + for version in [None, Some(1), Some(3)] { + let span = tracing::info_span!("version_endpoints", version); + let _enter = span.enter(); + tracing::info!("test version endpoints"); + + let prefix = match version { + Some(v) => format!("/v{v}"), + None => "".into(), + }; + let res = client + .get(&format!("{prefix}/mod/version")) + .send() + .await + .unwrap(); + assert_eq!( + res.json::() + .await + .unwrap() + .api_version + .unwrap() + .major, + version.unwrap_or(3) + ); + } + + // Test the application version. + let res = client.get("version").send().await.unwrap(); + assert_eq!( + res.json::().await.unwrap().modules["mod"], + [ + ApiVersion { + api_version: Some("3.0.0".parse().unwrap()), + spec_version: "0.1.0".parse().unwrap(), + }, + ApiVersion { + api_version: Some("1.0.0".parse().unwrap()), + spec_version: "0.1.0".parse().unwrap(), + } + ] + ); + + // Test healthcheck endpoints. + for version in [None, Some(1), Some(3)] { + let span = tracing::info_span!("healthcheck_endpoints", version); + let _enter = span.enter(); + tracing::info!("test healthcheck endpoints"); + + let prefix = match version { + Some(v) => format!("/v{v}"), + None => "".into(), + }; + let res = client + .get(&format!("{prefix}/mod/healthcheck")) + .send() + .await + .unwrap(); + let status = res.status(); + let health: HealthStatus = res.json().await.unwrap(); + assert_eq!(health.status(), status); + assert_eq!( + health, + if version == Some(1) { + HealthStatus::TemporarilyUnavailable + } else { + HealthStatus::Available + } + ); + } + + // Test the application health. + let res = client.get("healthcheck").send().await.unwrap(); + assert_eq!(res.status(), StatusCode::ServiceUnavailable); + let health: AppHealth = res.json().await.unwrap(); + assert_eq!(health.status, HealthStatus::Unhealthy); + assert_eq!( + health.modules["mod"], + [(3, StatusCode::Ok), (1, StatusCode::ServiceUnavailable)].into() + ); + } + + #[async_std::test] + async fn test_api_disco() { + setup_test(); + + // Test discoverability documentation when a request is for an unknown API. + let mut app = App::<_, ServerError, StaticVer01>::with_state(()); + app.module::( + "the-correct-module", + toml! { + route = {} + }, + ) + .unwrap() + .with_version("1.0.0".parse().unwrap()); + + let port = pick_unused_port().unwrap(); + let url: Url = format!("http://localhost:{}", port).parse().unwrap(); + spawn(app.serve(format!("0.0.0.0:{}", port), VER_0_1)); + let client = Client::new(url.clone()).await; + + let expected_list_item = html! { + a href="/the-correct-module" {"the-correct-module"} + sup { + a href="/v1/the-correct-module" {"[v1]"} + } + } + .into_string(); + + for version_prefix in ["", "/v1"] { + let docs = client + .get(&format!("{version_prefix}/test")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!(docs.contains("No API matches /test"), "{docs}"); + assert!(docs.contains(&expected_list_item), "{docs}"); + } + + // Top level documentation. + let docs = client.get("").send().await.unwrap().text().await.unwrap(); + assert!(!docs.contains("No API matches"), "{docs}"); + assert!(docs.contains(&expected_list_item), "{docs}"); + + let docs = client + .get("/v1") + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!( + docs.contains("illegal version prefix without API specifier"), + "{docs}" + ); + assert!(docs.contains(&expected_list_item), "{docs}"); + } + + #[async_std::test] + async fn test_post_redirect_idempotency() { + setup_test(); + + let mut app = App::<_, ServerError, StaticVer01>::with_state(RwLock::new(0)); + + let api_toml = toml! { + [meta] + FORMAT_VERSION = "0.1.0" + + [route.test] + METHOD = "POST" + PATH = ["/test"] + }; + { + let mut api = app.module::("mod", api_toml.clone()).unwrap(); + api.post("test", |_req, state| { + async move { + *state += 1; + Ok(*state) + } + .boxed() + }) + .unwrap(); + } + + let port = pick_unused_port().unwrap(); + let url: Url = format!("http://localhost:{}", port).parse().unwrap(); + spawn(app.serve(format!("0.0.0.0:{}", port), VER_0_1)); + let client = Client::new(url.clone()).await; + + for i in 1..3 { + // Request gets redirected to latest version of API and resent, but endpoint handler + // only executes once. + assert_eq!( + client + .post("mod/test") + .send() + .await + .unwrap() + .json::() + .await + .unwrap(), + i + ); + } + } } diff --git a/src/lib.rs b/src/lib.rs index 0709f188..108de827 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -289,6 +289,7 @@ pub mod request; pub mod route; pub mod socket; pub mod status; +pub mod testing; pub use api::Api; pub use app::App; @@ -683,7 +684,12 @@ pub fn app_api_path(org_name: &str, app_name: &str) -> PathBuf { pub async fn wait_for_server(url: &Url, retries: u64, sleep_ms: u64) { let dur = Duration::from_millis(sleep_ms); for _ in 0..retries { - if surf::connect(url).send().await.is_ok() { + if reqwest::Client::new() + .head(url.clone()) + .send() + .await + .is_ok() + { return; } sleep(dur).await; diff --git a/src/route.rs b/src/route.rs index 40b769b2..d63712e0 100644 --- a/src/route.rs +++ b/src/route.rs @@ -43,6 +43,7 @@ use versioned_binary_serialization::{version::StaticVersionType, BinarySerialize /// [RouteError] encapsulates application specific errors `E` returned by the user-installed handler /// itself. It also includes errors in the route dispatching logic, such as failures to turn the /// result of the user-installed handler into an HTTP response. +#[derive(Debug)] pub enum RouteError { AppSpecific(E), Request(RequestError), @@ -257,7 +258,7 @@ pub struct Route { handler: RouteImplementation, } -#[derive(Clone, Debug, Snafu)] +#[derive(Clone, Copy, Debug, Snafu, PartialEq, Eq)] pub enum RouteParseError { MissingPathArray, PathElementError, @@ -505,7 +506,9 @@ impl Route { /// Print documentation about the route, to aid the developer when the route is not yet /// implemented. pub(crate) fn default_handler(&self) -> Result> { - Ok(self.documentation().into()) + Ok(tide::Response::builder(StatusCode::NotImplemented) + .body(self.documentation().into_string()) + .build()) } pub(crate) async fn handle_socket( diff --git a/src/status.rs b/src/status.rs index 649758e0..78387763 100644 --- a/src/status.rs +++ b/src/status.rs @@ -476,6 +476,30 @@ impl PartialEq for tide::StatusCode { } } +impl From for reqwest::StatusCode { + fn from(code: StatusCode) -> Self { + reqwest::StatusCode::from_u16(code.into()).unwrap() + } +} + +impl From for StatusCode { + fn from(code: reqwest::StatusCode) -> Self { + code.as_u16().try_into().unwrap() + } +} + +impl PartialEq for StatusCode { + fn eq(&self, other: &reqwest::StatusCode) -> bool { + *self == Self::from(*other) + } +} + +impl PartialEq for reqwest::StatusCode { + fn eq(&self, other: &StatusCode) -> bool { + *self == Self::from(*other) + } +} + impl Display for StatusCode { fn fmt(&self, f: &mut Formatter) -> fmt::Result { write!(f, "{}", *self as u16) @@ -547,6 +571,10 @@ mod test { tide::StatusCode::try_from(code).unwrap(), tide::StatusCode::from(status) ); + assert_eq!( + reqwest::StatusCode::from_u16(code).unwrap(), + reqwest::StatusCode::from(status) + ); assert_eq!(code, u16::from(status)); // Test binary round trip. @@ -576,6 +604,7 @@ mod test { // Test equality. assert_eq!(status, tide::StatusCode::from(status)); + assert_eq!(status, reqwest::StatusCode::from(status)); } // Now iterate over all valid _Tide_ status codes, and ensure the ycan be converted to our @@ -589,5 +618,17 @@ mod test { StatusCode::from(status) ); } + + // Now iterate over all valid _reqwest_ status codes, and ensure the ycan be converted to + // our `StatusCode`. + for code in 0u16.. { + let Ok(status) = reqwest::StatusCode::from_u16(code) else { + break; + }; + assert_eq!( + StatusCode::try_from(code).unwrap(), + StatusCode::from(status) + ); + } } } diff --git a/src/testing.rs b/src/testing.rs new file mode 100644 index 00000000..52c56b13 --- /dev/null +++ b/src/testing.rs @@ -0,0 +1,78 @@ +#![cfg(any(test, feature = "testing"))] + +use crate::{http::Method, wait_for_server, Url, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS}; +use async_compatibility_layer::logging::{setup_backtrace, setup_logging}; +use async_tungstenite::{ + async_std::{connect_async, ConnectStream}, + tungstenite::{client::IntoClientRequest, http::header::*, Error as WsError}, + WebSocketStream, +}; +use reqwest::RequestBuilder; +use std::time::Duration; + +pub struct Client { + inner: reqwest::Client, + base_url: Url, +} + +impl Client { + pub async fn new(base_url: Url) -> Self { + wait_for_server(&base_url, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS).await; + Self { + inner: reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .unwrap(), + base_url, + } + } + + pub fn request(&self, method: Method, path: &str) -> RequestBuilder { + let req_method: reqwest::Method = method.to_string().parse().unwrap(); + self.inner + .request(req_method, self.base_url.join(path).unwrap()) + } + + pub fn get(&self, path: &str) -> RequestBuilder { + self.request(Method::Get, path) + } + + pub fn post(&self, path: &str) -> RequestBuilder { + self.request(Method::Post, path) + } +} + +pub fn setup_test() { + setup_logging(); + setup_backtrace(); +} + +pub async fn test_ws_client(url: Url) -> WebSocketStream { + test_ws_client_with_headers(url, &[]).await +} + +pub async fn test_ws_client_with_headers( + mut url: Url, + headers: &[(HeaderName, &str)], +) -> WebSocketStream { + wait_for_server(&url, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS).await; + url.set_scheme("ws").unwrap(); + + // Follow redirects. + loop { + let mut req = url.clone().into_client_request().unwrap(); + for (name, value) in headers { + req.headers_mut().insert(name, value.parse().unwrap()); + } + + match connect_async(req).await { + Ok((conn, _)) => return conn, + Err(WsError::Http(res)) if (301..=308).contains(&u16::from(res.status())) => { + let location = res.headers()["location"].to_str().unwrap(); + tracing::info!(from = %url, to = %location, "WS handshake following redirect"); + url.set_path(location); + } + Err(err) => panic!("socket connection failed: {err}"), + } + } +}