From ceaad46b3ec6376d7ebdc54f0bef84242e0c0182 Mon Sep 17 00:00:00 2001 From: Jeb Bearer Date: Mon, 25 Mar 2024 15:30:25 -0400 Subject: [PATCH 1/3] Support multiple versions of the same API This gives us the capability of making backwards-compatible updates to a system in production, which in turn reduces the pressure to get all of our APIs perfect before a launch. If we want to remove a problematic endpoint or make backwards-incompatible changes after release, we can release a new major version of the API with those changes, and continue to serve the old version under a version prefix. We can tell existing clients to point at the old version of the API, so they can continue to run for some period of time before updating their software. This also allows us to do hotfixes in production. For example, suppose there is some bug which we cannot fix without a breaking change to the API. We can add a new route with a `/hotfix` prefix or suffix. Only the affected clients need to change their code to use the hotfix endpoint. In the next major version, the hotfix endpoint can replace the original. Note that we can add new methods and make other backwards-compatible changes with only a minor version bump, which can replace the existing API instead of deploying a new version. Closes #185 --- .github/workflows/build.yml | 6 +- .github/workflows/build_windows.yml | 6 +- .github/workflows/coverage.yml | 4 +- Cargo.lock | 572 +++++++++++++++++++- Cargo.toml | 15 +- examples/hello-world/main.rs | 63 +-- examples/versions/main.rs | 118 ++++ examples/versions/v1.toml | 16 + examples/versions/v2.toml | 16 + flake.nix | 9 + src/api.rs | 107 ++-- src/app.rs | 810 ++++++++++++++++++++++------ src/lib.rs | 1 + src/route.rs | 7 +- src/testing.rs | 52 ++ 15 files changed, 1498 insertions(+), 304 deletions(-) create mode 100644 examples/versions/main.rs create mode 100644 examples/versions/v1.toml create mode 100644 examples/versions/v2.toml create mode 100644 src/testing.rs 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..151932a8 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 0.11.0", + "futures", + "tokio", + "tokio-stream", + "tracing", + "tracing-error", + "tracing-subscriber", +] + [[package]] name = "async-dup" version = "1.2.2" @@ -347,6 +367,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 +445,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 1.4.0", + "futures-util", + "http 0.2.11", + "http-body", + "hyper", + "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 1.4.0", + "futures-util", + "http 0.2.11", + "http-body", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + [[package]] name = "backtrace" version = "0.3.68" @@ -594,6 +681,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 +743,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" @@ -714,6 +865,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" @@ -998,6 +1168,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,6 +1187,16 @@ dependencies = [ "instant", ] +[[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" @@ -1018,6 +1208,18 @@ dependencies = [ "spinning_top", ] +[[package]] +name = "flume" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1165,8 +1367,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 +1401,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "h2" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fbd2820c5e49886948654ab546d0688ff24530286bdcf8fca3cefb16d4618eb" +dependencies = [ + "bytes 1.4.0", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.11", + "indexmap 2.0.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1215,6 +1438,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" @@ -1281,6 +1517,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes 1.4.0", + "http 0.2.11", + "pin-project-lite 0.2.12", +] + [[package]] name = "http-client" version = "6.5.3" @@ -1323,6 +1570,54 @@ 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 1.4.0", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.11", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite 0.2.12", + "socket2 0.4.9", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper", + "pin-project-lite 0.2.12", + "tokio", + "tokio-io-timeout", +] + [[package]] name = "iana-time-zone" version = "0.1.57" @@ -1381,6 +1676,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" @@ -1448,7 +1749,7 @@ dependencies = [ "crossbeam-utils", "curl", "curl-sys", - "flume", + "flume 0.9.2", "futures-lite", "http 0.2.11", "log", @@ -1470,6 +1771,15 @@ 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 = "itoa" version = "1.0.9" @@ -1590,6 +1900,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" @@ -1650,6 +1966,26 @@ 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 = "nom" version = "7.1.3" @@ -1711,6 +2047,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" @@ -1772,6 +2118,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" @@ -2002,6 +2354,38 @@ dependencies = [ "thiserror", ] +[[package]] +name = "prost" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c289cda302b98a28d40c8b3b90498d6e526dd24ac2ecea73e4e491685b94a" +dependencies = [ + "bytes 1.4.0", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efb6c9a1dd1def8e2124d17e83a20af56f1570d6c2d2bd9e266ccb768df3840e" +dependencies = [ + "anyhow", + "itertools 0.11.0", + "proc-macro2", + "quote", + "syn 2.0.53", +] + +[[package]] +name = "prost-types" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "193898f59edcf43c26227dcd4c8427f00d99d61e95dcde58dabd49fa291d470e" +dependencies = [ + "prost", +] + [[package]] name = "protobuf" version = "2.28.0" @@ -2618,6 +3002,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "spinning_top" version = "0.2.5" @@ -2841,6 +3234,12 @@ 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 = "tagged-base64" version = "0.3.4" @@ -2923,6 +3322,7 @@ dependencies = [ "anyhow", "ark-serialize", "ark-std", + "async-compatibility-layer", "async-std", "async-trait", "async-tungstenite 0.25.0", @@ -2966,7 +3366,6 @@ dependencies = [ "tracing-futures", "tracing-log", "tracing-subscriber", - "tracing-test", "url", "versioned-binary-serialization", ] @@ -3079,6 +3478,72 @@ 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 1.4.0", + "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-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 1.4.0", + "futures-core", + "futures-sink", + "pin-project-lite 0.2.12", + "tokio", + "tracing", +] + [[package]] name = "toml" version = "0.8.8" @@ -3113,6 +3578,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 1.4.0", + "h2", + "http 0.2.11", + "http-body", + "hyper", + "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 +3676,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 +3745,10 @@ dependencies = [ ] [[package]] -name = "tracing-test" -version = "0.2.4" +name = "try-lock" +version = "0.2.5" 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" -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" @@ -3426,6 +3943,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" diff --git a/Cargo.toml b/Cargo.toml index c73d7b17..554850bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,18 @@ 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" @@ -56,12 +65,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..e1e00036 100644 --- a/examples/hello-world/main.rs +++ b/examples/hello-world/main.rs @@ -103,52 +103,41 @@ mod test { api::ApiVersion, app::{AppHealth, AppVersion}, healthcheck::HealthStatus, - wait_for_server, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS, + testing::{setup_test, test_client}, }; - 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 = test_client(url).await; - let mut res = surf::get(url.join("greeting/tester").unwrap()) - .send() - .await - .unwrap(); + let mut res = client.get("greeting/tester").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); assert_eq!(res.body_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 mut res = client.get("greeting/tester").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); assert_eq!(res.body_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 = test_client(url).await; // Check the API version. - let mut res = surf::get(url.join("hello/version").unwrap()) - .send() - .await - .unwrap(); + let mut 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()), @@ -157,37 +146,29 @@ mod test { assert_eq!(res.body_json::().await.unwrap(), api_version); // Check the overall version. - let mut res = surf::get(url.join("version").unwrap()) - .send() - .await - .unwrap(); + let mut res = client.get("version").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); assert_eq!( res.body_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 = test_client(url).await; // Check the API health. - let mut res = surf::get(url.join("hello/healthcheck").unwrap()) - .send() - .await - .unwrap(); + let mut 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!( @@ -196,19 +177,13 @@ mod test { ); // Check the overall health. - let mut res = surf::get(url.join("healthcheck").unwrap()) - .send() - .await - .unwrap(); + let mut res = client.get("healthcheck").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); assert_eq!( res.body_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..8ac779da --- /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, 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 = test_client(url).await; + + assert_eq!( + "deleted", + client + .get("api/v1/deleted") + .send() + .await + .unwrap() + .body_json::() + .await + .unwrap() + ); + assert_eq!( + StatusCode::NotFound, + client.get("api/v1/added").send().await.unwrap().status() + ); + + assert_eq!( + "added", + client + .get("api/v2/added") + .send() + .await + .unwrap() + .body_json::() + .await + .unwrap() + ); + assert_eq!( + StatusCode::NotFound, + client.get("api/v2/deleted").send().await.unwrap().status() + ); + + assert_eq!( + "added", + client + .get("api/added") + .send() + .await + .unwrap() + .body_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..8b43b608 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_client, test_ws_client, test_ws_client_with_headers}, + 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,12 +1607,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; + let client = test_client(url).await; - let mut res = surf::get(format!("http://localhost:{}/mod/healthcheck", port)) - .send() - .await - .unwrap(); + let mut res = client.get("/mod/healthcheck").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); assert_eq!( res.body_json::().await.unwrap(), @@ -1648,6 +1619,8 @@ mod test { #[async_std::test] async fn test_metrics_endpoint() { + setup_test(); + struct State { metrics: Registry, counter: Counter, @@ -1673,8 +1646,9 @@ mod test { }; { let mut api = app.module::("mod", api_toml).unwrap(); - api.metrics("metrics", |_req, state| { + api.metrics("metrics", |req, state| { async move { + tracing::info!(?req, "metrics called"); state.counter.inc(); Ok(Cow::Borrowed(&state.metrics)) } @@ -1685,11 +1659,12 @@ 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 = test_client(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(); + let mut res = client.get("mod/metrics").send().await.unwrap(); assert_eq!(res.body_string().await.unwrap(), expected); assert_eq!(res.status(), StatusCode::Ok); } diff --git a/src/app.rs b/src/app.rs index 3ac33a83..3bae90b2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -23,7 +23,10 @@ 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::collections::{ + btree_map::{BTreeMap, Entry as BTreeEntry}, + hash_map::{Entry as HashEntry, HashMap}, +}; use std::convert::Infallible; use std::env; use std::fs; @@ -45,15 +48,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 +79,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 +94,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 +102,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 +142,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 +209,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 +226,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 +248,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 +298,6 @@ 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(add_error_body::<_, Error, VER>); server.with( CorsMiddleware::new() @@ -251,87 +307,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`. @@ -366,19 +343,17 @@ impl< } { server - .at("/*") + .at("/*path") .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()) - }) - } + let docs = html! { + "No route matches /" (req.param("path")?) + br {} + "This is a Tide Disco app composed of the following modules:" + (req.state().list_apis()) + }; + Ok(tide::Response::builder(StatusCode::NotFound) + .body(docs.into_string()) + .build()) }); } @@ -388,19 +363,215 @@ 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!("/{name}/v{version}")) { + (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)?; + } + + let latest_version = *versions.last_key_value().unwrap().0; + + // Serve the documentation for the latest supported version at the root of the module. + server + .at(&prefix) + .all(tide::Redirect::new(format!("/{prefix}/v{latest_version}"))); + + // For requests that didn't match any specific endpoint, parse the version prefix. If there + // is none, redirect to the latest supported version. If there is one and the request still + // didn't match, it is invalid. Try to serve helpful documentation. + server + .at(&prefix) + .at("*path") + .all(move |req: tide::Request>| { + let prefix = prefix.clone(); + async move { + let path = req.param("path")?; + // Split the first path segment from the rest so we can check if the first + // segment is a version prefix. + let (first, rest) = path.split_once('/').unwrap_or((path, "")); + + // Check for a version prefix. + if let Some(v) = first.strip_prefix('v').and_then(|v| v.parse().ok()) { + let versions = &req.state().apis[&prefix]; + if let Some(api) = versions.get(&v) { + // The request has a valid version prefix, but did not match any route + // (hence this wildcard handler). Serve documentation for the intended + // API version. + let docs = html! { + "No route matches /" (rest) + br{} + (api.documentation()) + }; + return Ok(tide::Response::builder(StatusCode::NotFound) + .body(docs.into_string()) + .build()); + } else { + // The request has a version prefix for an unsupported version. List + // supported versions. + let docs = html! { + "Unsupported version v" (v) ". Supported versions are:" + ul { + @for v in versions.keys().rev() { + li { + a href=(format!("/{prefix}/v{v}")) { "v" (v) } + } + } + } + }; + return Ok(tide::Response::builder(StatusCode::NotImplemented) + .body(docs.into_string()) + .build()); + } } + + // The request has no version prefix, redirect to the latest supported version. + Ok(tide::Redirect::new(format!("/{prefix}/v{latest_version}/{path}")).into()) } + }); + + 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(prefix) + .at(&format!("v{version}")) + .serve_dir(api.public().unwrap_or_else(|| &DEFAULT_PUBLIC_PATH))?; + + // Register routes for this API. + let mut api_endpoint = server.at(&format!("{prefix}/v{version}")); + 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`. + { + let prefix = prefix.clone(); + api_endpoint.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("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 +582,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 +599,7 @@ impl< fn register_metrics( api: String, + version: u64, endpoint: &mut tide::Route>, route: &Route, bind_version: VER, @@ -440,6 +612,7 @@ impl< endpoint.with(MetricsMiddleware::new( name.clone(), api.clone(), + version, bind_version, )); } @@ -453,11 +626,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 +646,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 +674,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 +688,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 { @@ -529,12 +704,18 @@ impl< 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 +737,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 +756,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 +789,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 +803,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 +854,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 +915,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 +948,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_client, test_ws_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 +980,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,12 +1050,7 @@ 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 = test_client(url.clone()).await; // Regular HTTP methods. for method in [Get, Post, Put, Delete] { @@ -866,9 +1084,7 @@ mod test { assert_eq!(res.status(), StatusCode::Ok); // 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 +1097,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,12 +1126,7 @@ 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 = test_client(url.clone()).await; let mut res = client .get(url.join("mod/test/a/42").unwrap()) @@ -937,4 +1150,275 @@ mod test { ("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 = test_client(url.clone()).await; + + // First check that we can call all the expected methods. + assert_eq!( + "deleted v1", + client + .get("mod/v1/deleted") + .send() + .await + .unwrap() + .body_json::() + .await + .unwrap() + ); + assert_eq!( + "unchanged v1", + client + .get("mod/v1/unchanged") + .send() + .await + .unwrap() + .body_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!("mod{prefix}/added")) + .send() + .await + .unwrap() + .body_json::() + .await + .unwrap() + ); + assert_eq!( + "unchanged v3", + client + .get(&format!("mod{prefix}/unchanged")) + .send() + .await + .unwrap() + .body_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 mut res = client + .get(&format!("mod{prefix}{route}")) + .send() + .await + .unwrap(); + let docs = res.body_string().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="/mod/v3" {"v3"} + } + li { + a href="/mod/v1" {"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 mut res = client.get(&format!("mod/v2{route}")).send().await.unwrap(); + let docs = res.body_string().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 mut res = client + .get(&format!("mod{prefix}/version")) + .send() + .await + .unwrap(); + assert_eq!( + res.body_json::() + .await + .unwrap() + .api_version + .unwrap() + .major, + version.unwrap_or(3) + ); + } + + // Test the application version. + let mut res = client.get("version").send().await.unwrap(); + assert_eq!( + res.body_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 mut res = client + .get(&format!("mod{prefix}/healthcheck")) + .send() + .await + .unwrap(); + let health: HealthStatus = res.body_json().await.unwrap(); + assert_eq!(health.status(), res.status()); + assert_eq!( + health, + if version == Some(1) { + HealthStatus::TemporarilyUnavailable + } else { + HealthStatus::Available + } + ); + } + + // Test the application health. + let mut res = client.get("healthcheck").send().await.unwrap(); + let health: AppHealth = res.body_json().await.unwrap(); + assert_eq!(health.status, HealthStatus::Unhealthy); + assert_eq!(res.status(), StatusCode::ServiceUnavailable); + assert_eq!( + health.modules["mod"], + [(3, StatusCode::Ok), (1, StatusCode::ServiceUnavailable)].into() + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 0709f188..b99c5701 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; 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/testing.rs b/src/testing.rs new file mode 100644 index 00000000..36bfa949 --- /dev/null +++ b/src/testing.rs @@ -0,0 +1,52 @@ +#![cfg(any(test, feature = "testing"))] + +use crate::{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 surf::{middleware::Redirect, Client, Config}; + +pub fn setup_test() { + setup_logging(); + setup_backtrace(); +} + +pub async fn test_client(url: Url) -> surf::Client { + wait_for_server(&url, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS).await; + Client::try_from(Config::new().set_base_url(url)) + .unwrap() + .with(Redirect::default()) +} + +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 res.status() == 302 => { + 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}"), + } + } +} From 7fcce2e5ffcb779eaa55a7ec2a8c35275f8a034f Mon Sep 17 00:00:00 2001 From: Jeb Bearer Date: Tue, 26 Mar 2024 15:26:20 -0400 Subject: [PATCH 2/3] Switch HTTP client for testing from surf to reqwest Surf does not handle redirects correctly: it ends up sending the same request multiple times _after_ the redirect, which is problematic for methods with side-effects. --- Cargo.lock | 570 ++++++++++++++++++++++------------- Cargo.toml | 4 +- examples/hello-world/main.rs | 34 +-- examples/versions/main.rs | 10 +- src/api.rs | 14 +- src/app.rs | 140 ++++++--- src/lib.rs | 7 +- src/status.rs | 41 +++ src/testing.rs | 46 ++- 9 files changed, 568 insertions(+), 298 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 151932a8..17c4e843 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -214,7 +214,7 @@ dependencies = [ "async-trait", "color-eyre", "console-subscriber", - "flume 0.11.0", + "flume", "futures", "tokio", "tokio-stream", @@ -242,7 +242,7 @@ dependencies = [ "async-lock", "async-task", "concurrent-queue", - "fastrand", + "fastrand 1.9.0", "futures-lite", "slab", ] @@ -260,6 +260,7 @@ dependencies = [ "blocking", "futures-lite", "once_cell", + "tokio", ] [[package]] @@ -292,7 +293,7 @@ dependencies = [ "log", "parking", "polling", - "rustix", + "rustix 0.37.25", "slab", "socket2 0.4.9", "waker-fn", @@ -320,7 +321,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 0.37.25", "signal-hook", "windows-sys 0.48.0", ] @@ -454,11 +455,11 @@ dependencies = [ "async-trait", "axum-core", "bitflags 1.3.2", - "bytes 1.4.0", + "bytes", "futures-util", "http 0.2.11", - "http-body", - "hyper", + "http-body 0.4.6", + "hyper 0.14.28", "itoa", "matchit", "memchr", @@ -480,10 +481,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" dependencies = [ "async-trait", - "bytes 1.4.0", + "bytes", "futures-util", "http 0.2.11", - "http-body", + "http-body 0.4.6", "mime", "rustversion", "tower-layer", @@ -575,7 +576,7 @@ dependencies = [ "async-lock", "async-task", "atomic-waker", - "fastrand", + "fastrand 1.9.0", "futures-lite", "log", ] @@ -592,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" @@ -838,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" @@ -928,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" @@ -1143,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]] @@ -1187,6 +1150,12 @@ 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" @@ -1197,17 +1166,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "flume" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bebadab126f8120d410b677ed95eee4ba6eb7c6dd8e34a5ec88a08050e26132" -dependencies = [ - "futures-core", - "futures-sink", - "spinning_top", -] - [[package]] name = "flume" version = "0.11.0" @@ -1226,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" @@ -1289,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", @@ -1407,7 +1380,7 @@ version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fbd2820c5e49886948654ab546d0688ff24530286bdcf8fca3cefb16d4618eb" dependencies = [ - "bytes 1.4.0", + "bytes", "fnv", "futures-core", "futures-sink", @@ -1420,6 +1393,25 @@ dependencies = [ "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" @@ -1501,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", ] @@ -1512,7 +1504,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b32afd38673a8016f7c9ae69e5af41a58f81b1d31689040f2f1959594ce194ea" dependencies = [ - "bytes 1.4.0", + "bytes", "fnv", "itoa", ] @@ -1523,22 +1515,43 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ - "bytes 1.4.0", + "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", ] @@ -1588,36 +1601,92 @@ version = "0.14.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" dependencies = [ - "bytes 1.4.0", + "bytes", "futures-channel", "futures-core", "futures-util", - "h2", + "h2 0.3.25", "http 0.2.11", - "http-body", + "http-body 0.4.6", "httparse", "httpdate", "itoa", "pin-project-lite 0.2.12", - "socket2 0.4.9", + "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", + "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" @@ -1716,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]] @@ -1740,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 0.9.2", - "futures-lite", - "http 0.2.11", - "log", - "once_cell", - "slab", - "sluice", - "tracing", - "tracing-futures", - "url", - "waker-fn", -] +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" [[package]] name = "itertools" @@ -1827,28 +1879,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" @@ -1861,6 +1891,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" @@ -1941,16 +1977,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" @@ -1986,6 +2012,24 @@ 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" @@ -2078,6 +2122,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" @@ -2086,9 +2156,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", @@ -2360,7 +2430,7 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c289cda302b98a28d40c8b3b90498d6e526dd24ac2ecea73e4e491685b94a" dependencies = [ - "bytes 1.4.0", + "bytes", "prost-derive", ] @@ -2545,6 +2615,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" @@ -2617,10 +2729,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" @@ -2648,6 +2782,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" @@ -2901,22 +3058,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" @@ -3011,15 +3157,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spinning_top" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b9eb1a2f4c41445a3a0ff9abc5221c5fcd28e1f13cd7c0397706f9ac938ddb0" -dependencies = [ - "lock_api", -] - [[package]] name = "standback" version = "0.2.17" @@ -3121,29 +3258,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" @@ -3240,6 +3354,27 @@ 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" @@ -3264,6 +3399,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" @@ -3345,6 +3492,7 @@ dependencies = [ "parking_lot", "portpicker", "prometheus", + "reqwest", "routefinder", "semver 1.0.22", "serde", @@ -3356,7 +3504,6 @@ dependencies = [ "snafu 0.8.2", "strum", "strum_macros", - "surf", "tagged-base64", "tide", "tide-websockets", @@ -3485,7 +3632,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" dependencies = [ "backtrace", - "bytes 1.4.0", + "bytes", "libc", "mio", "num_cpus", @@ -3519,6 +3666,16 @@ dependencies = [ "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" @@ -3536,7 +3693,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" dependencies = [ - "bytes 1.4.0", + "bytes", "futures-core", "futures-sink", "pin-project-lite 0.2.12", @@ -3588,11 +3745,11 @@ dependencies = [ "async-trait", "axum", "base64 0.21.2", - "bytes 1.4.0", - "h2", + "bytes", + "h2 0.3.25", "http 0.2.11", - "http-body", - "hyper", + "http-body 0.4.6", + "hyper 0.14.28", "hyper-timeout", "percent-encoding", "pin-project", @@ -3758,7 +3915,7 @@ checksum = "5fe8dada8c1a3aeca77d6b51a4f1314e0f4b8e438b7b1b71e3ddaca8080e4093" dependencies = [ "base64 0.13.1", "byteorder", - "bytes 1.4.0", + "bytes", "http 0.2.11", "httparse", "input_buffer", @@ -3777,7 +3934,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", @@ -3801,15 +3958,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" @@ -4214,6 +4362,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 554850bf..e40dfcb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ 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" @@ -42,6 +42,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"] } @@ -52,7 +53,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" diff --git a/examples/hello-world/main.rs b/examples/hello-world/main.rs index e1e00036..7772809f 100644 --- a/examples/hello-world/main.rs +++ b/examples/hello-world/main.rs @@ -98,12 +98,12 @@ 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, - testing::{setup_test, test_client}, + testing::{setup_test, Client}, + Url, }; #[async_std::test] @@ -113,18 +113,18 @@ mod test { let port = pick_unused_port().unwrap(); spawn(serve(port)); let url = Url::parse(&format!("http://localhost:{}/hello/", port)).unwrap(); - let client = test_client(url).await; + let client = Client::new(url).await; - let mut res = client.get("greeting/tester").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 = client.post("greeting/Sup").send().await.unwrap(); assert_eq!(res.status(), StatusCode::Ok); - let mut res = client.get("greeting/tester").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] @@ -134,22 +134,22 @@ mod test { let port = pick_unused_port().unwrap(); spawn(serve(port)); let url = Url::parse(&format!("http://localhost:{}/", port)).unwrap(); - let client = test_client(url).await; + let client = Client::new(url).await; // Check the API version. - let mut res = client.get("hello/version").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 = client.get("version").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(), @@ -165,22 +165,22 @@ mod test { let port = pick_unused_port().unwrap(); spawn(serve(port)); let url = Url::parse(&format!("http://localhost:{}/", port)).unwrap(); - let client = test_client(url).await; + let client = Client::new(url).await; // Check the API health. - let mut res = client.get("hello/healthcheck").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 = client.get("healthcheck").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(), [(0, StatusCode::Ok)].into())].into(), diff --git a/examples/versions/main.rs b/examples/versions/main.rs index 8ac779da..645d5eb1 100644 --- a/examples/versions/main.rs +++ b/examples/versions/main.rs @@ -51,7 +51,7 @@ mod test { use async_std::task::spawn; use portpicker::pick_unused_port; use tide_disco::{ - testing::{setup_test, test_client}, + testing::{setup_test, Client}, StatusCode, Url, }; @@ -65,7 +65,7 @@ mod test { let port = pick_unused_port().unwrap(); spawn(serve(port)); let url = Url::parse(&format!("http://localhost:{}/", port)).unwrap(); - let client = test_client(url).await; + let client = Client::new(url).await; assert_eq!( "deleted", @@ -74,7 +74,7 @@ mod test { .send() .await .unwrap() - .body_json::() + .json::() .await .unwrap() ); @@ -90,7 +90,7 @@ mod test { .send() .await .unwrap() - .body_json::() + .json::() .await .unwrap() ); @@ -106,7 +106,7 @@ mod test { .send() .await .unwrap() - .body_json::() + .json::() .await .unwrap() ); diff --git a/src/api.rs b/src/api.rs index 8b43b608..52f8f05d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1322,7 +1322,7 @@ mod test { error::{Error, ServerError}, healthcheck::HealthStatus, socket::Connection, - testing::{setup_test, test_client, test_ws_client, test_ws_client_with_headers}, + testing::{setup_test, test_ws_client, test_ws_client_with_headers, Client}, App, StatusCode, Url, }; use async_std::{sync::RwLock, task::spawn}; @@ -1607,12 +1607,12 @@ 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)); - let client = test_client(url).await; + let client = Client::new(url).await; - let mut res = client.get("/mod/healthcheck").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 ); } @@ -1659,14 +1659,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)); - let client = test_client(url).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 = client.get("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 3bae90b2..efa34494 100644 --- a/src/app.rs +++ b/src/app.rs @@ -398,9 +398,9 @@ impl< let latest_version = *versions.last_key_value().unwrap().0; // Serve the documentation for the latest supported version at the root of the module. - server - .at(&prefix) - .all(tide::Redirect::new(format!("/{prefix}/v{latest_version}"))); + server.at(&prefix).all(tide::Redirect::permanent(format!( + "/{prefix}/v{latest_version}" + ))); // For requests that didn't match any specific endpoint, parse the version prefix. If there // is none, redirect to the latest supported version. If there is one and the request still @@ -451,7 +451,10 @@ impl< } // The request has no version prefix, redirect to the latest supported version. - Ok(tide::Redirect::new(format!("/{prefix}/v{latest_version}/{path}")).into()) + Ok( + tide::Redirect::permanent(format!("/{prefix}/v{latest_version}/{path}")) + .into(), + ) } }); @@ -951,7 +954,7 @@ mod test { error::ServerError, metrics::Metrics, socket::Connection, - testing::{setup_test, test_client, test_ws_client}, + testing::{setup_test, test_ws_client, Client}, Url, }; use async_std::{sync::RwLock, task::spawn}; @@ -1050,38 +1053,34 @@ 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)); - let client = test_client(url.clone()).await; + 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 conn = test_ws_client(url.join("mod/test").unwrap()).await; @@ -1126,27 +1125,19 @@ 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)); - let client = test_client(url.clone()).await; + 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()) ); } @@ -1219,7 +1210,7 @@ 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)); - let client = test_client(url.clone()).await; + let client = Client::new(url.clone()).await; // First check that we can call all the expected methods. assert_eq!( @@ -1229,7 +1220,7 @@ mod test { .send() .await .unwrap() - .body_json::() + .json::() .await .unwrap() ); @@ -1240,7 +1231,7 @@ mod test { .send() .await .unwrap() - .body_json::() + .json::() .await .unwrap() ); @@ -1256,7 +1247,7 @@ mod test { .send() .await .unwrap() - .body_json::() + .json::() .await .unwrap() ); @@ -1267,7 +1258,7 @@ mod test { .send() .await .unwrap() - .body_json::() + .json::() .await .unwrap() ); @@ -1290,12 +1281,12 @@ mod test { // the latest supported version. let version = version.unwrap_or(3); - let mut res = client + let res = client .get(&format!("mod{prefix}{route}")) .send() .await .unwrap(); - let docs = res.body_string().await.unwrap(); + let docs = res.text().await.unwrap(); if !route.is_empty() { assert!( docs.contains(&format!("No route matches {route}")), @@ -1337,8 +1328,8 @@ mod test { let _enter = span.enter(); tracing::info!("test unsupported version docs"); - let mut res = client.get(&format!("mod/v2{route}")).send().await.unwrap(); - let docs = res.body_string().await.unwrap(); + let res = client.get(&format!("mod/v2{route}")).send().await.unwrap(); + let docs = res.text().await.unwrap(); assert_eq!(docs, expected_html); } @@ -1352,13 +1343,13 @@ mod test { Some(v) => format!("/v{v}"), None => "".into(), }; - let mut res = client + let res = client .get(&format!("mod{prefix}/version")) .send() .await .unwrap(); assert_eq!( - res.body_json::() + res.json::() .await .unwrap() .api_version @@ -1369,9 +1360,9 @@ mod test { } // Test the application version. - let mut res = client.get("version").send().await.unwrap(); + let res = client.get("version").send().await.unwrap(); assert_eq!( - res.body_json::().await.unwrap().modules["mod"], + res.json::().await.unwrap().modules["mod"], [ ApiVersion { api_version: Some("3.0.0".parse().unwrap()), @@ -1394,13 +1385,14 @@ mod test { Some(v) => format!("/v{v}"), None => "".into(), }; - let mut res = client + let res = client .get(&format!("mod{prefix}/healthcheck")) .send() .await .unwrap(); - let health: HealthStatus = res.body_json().await.unwrap(); - assert_eq!(health.status(), res.status()); + let status = res.status(); + let health: HealthStatus = res.json().await.unwrap(); + assert_eq!(health.status(), status); assert_eq!( health, if version == Some(1) { @@ -1412,13 +1404,61 @@ mod test { } // Test the application health. - let mut res = client.get("healthcheck").send().await.unwrap(); - let health: AppHealth = res.body_json().await.unwrap(); - assert_eq!(health.status, HealthStatus::Unhealthy); + 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_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 b99c5701..108de827 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -684,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/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 index 36bfa949..52c56b13 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -1,26 +1,52 @@ #![cfg(any(test, feature = "testing"))] -use crate::{wait_for_server, Url, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS}; +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 surf::{middleware::Redirect, Client, Config}; +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_client(url: Url) -> surf::Client { - wait_for_server(&url, SERVER_STARTUP_RETRIES, SERVER_STARTUP_SLEEP_MS).await; - Client::try_from(Config::new().set_base_url(url)) - .unwrap() - .with(Redirect::default()) -} - pub async fn test_ws_client(url: Url) -> WebSocketStream { test_ws_client_with_headers(url, &[]).await } @@ -41,7 +67,7 @@ pub async fn test_ws_client_with_headers( match connect_async(req).await { Ok((conn, _)) => return conn, - Err(WsError::Http(res)) if res.status() == 302 => { + 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); From 3882a1f2b6d4b0c1ece264ab025cf13094fd63c9 Mon Sep 17 00:00:00 2001 From: Jeb Bearer Date: Tue, 26 Mar 2024 17:00:03 -0400 Subject: [PATCH 3/3] Put version prefix _before_ API prefix, instead of after The rationale is that now a base URL like `https://hostname/v1` can be passed into a client service, which can then access multiple API modules from that URL, as long as all the modules support v1. Different modules are still allowed to support different versions, but this is not the common case. This makes request parsing and routing a bit more complicated, but things are greatly simplified by using a middleware to handle version redirects. Now all the redirect code is isolated and separate from the actual logic of handling routes. --- Cargo.lock | 10 ++ Cargo.toml | 1 + examples/versions/main.rs | 8 +- src/api.rs | 3 +- src/app.rs | 353 +++++++++++++++++++++++++------------- 5 files changed, 245 insertions(+), 130 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 17c4e843..e0164814 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1832,6 +1832,15 @@ 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" @@ -3483,6 +3492,7 @@ dependencies = [ "futures-util", "http 1.0.0", "include_dir", + "itertools 0.12.1", "lazy_static", "libc", "markdown", diff --git a/Cargo.toml b/Cargo.toml index e40dfcb7..886aa98d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,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" diff --git a/examples/versions/main.rs b/examples/versions/main.rs index 645d5eb1..97b780a0 100644 --- a/examples/versions/main.rs +++ b/examples/versions/main.rs @@ -70,7 +70,7 @@ mod test { assert_eq!( "deleted", client - .get("api/v1/deleted") + .get("v1/api/deleted") .send() .await .unwrap() @@ -80,13 +80,13 @@ mod test { ); assert_eq!( StatusCode::NotFound, - client.get("api/v1/added").send().await.unwrap().status() + client.get("v1/api/added").send().await.unwrap().status() ); assert_eq!( "added", client - .get("api/v2/added") + .get("v2/api/added") .send() .await .unwrap() @@ -96,7 +96,7 @@ mod test { ); assert_eq!( StatusCode::NotFound, - client.get("api/v2/deleted").send().await.unwrap().status() + client.get("v2/api/deleted").send().await.unwrap().status() ); assert_eq!( diff --git a/src/api.rs b/src/api.rs index 52f8f05d..f947016a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1646,9 +1646,8 @@ mod test { }; { let mut api = app.module::("mod", api_toml).unwrap(); - api.metrics("metrics", |req, state| { + api.metrics("metrics", |_req, state| { async move { - tracing::info!(?req, "metrics called"); state.counter.inc(); Ok(Cow::Borrowed(&state.metrics)) } diff --git a/src/app.rs b/src/app.rs index efa34494..cc1e9f3a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,22 +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::{ - btree_map::{BTreeMap, Entry as BTreeEntry}, - hash_map::{Entry as HashEntry, HashMap}, +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 std::convert::Infallible; -use std::env; -use std::fs; -use std::io; -use std::ops::{Deref, DerefMut}; -use std::path::PathBuf; use tide::{ http::{headers::HeaderValue, mime}, security::{CorsMiddleware, Origin}, @@ -298,6 +301,7 @@ impl< ) -> io::Result<()> { let state = Arc::new(self); let mut server = tide::Server::with_state(state.clone()); + server.with(Self::version_middleware); server.with(add_error_body::<_, Error, VER>); server.with( CorsMiddleware::new() @@ -330,32 +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("/*path") - .all(move |req: tide::Request>| async move { - let docs = html! { - "No route matches /" (req.param("path")?) - br {} - "This is a Tide Disco app composed of the following modules:" - (req.state().list_apis()) - }; - Ok(tide::Response::builder(StatusCode::NotFound) - .body(docs.into_string()) - .build()) - }); - } + // 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 } @@ -371,7 +355,7 @@ impl< // version, linking to documentation for that specific version. @for version in versions.keys().rev() { sup { - a href=(format!("/{name}/v{version}")) { + a href=(format!("/v{version}/{name}")) { (format!("[v{version}]")) } } @@ -394,70 +378,6 @@ impl< for (version, api) in versions { Self::register_api_version(server, &prefix, *version, api, bind_version)?; } - - let latest_version = *versions.last_key_value().unwrap().0; - - // Serve the documentation for the latest supported version at the root of the module. - server.at(&prefix).all(tide::Redirect::permanent(format!( - "/{prefix}/v{latest_version}" - ))); - - // For requests that didn't match any specific endpoint, parse the version prefix. If there - // is none, redirect to the latest supported version. If there is one and the request still - // didn't match, it is invalid. Try to serve helpful documentation. - server - .at(&prefix) - .at("*path") - .all(move |req: tide::Request>| { - let prefix = prefix.clone(); - async move { - let path = req.param("path")?; - // Split the first path segment from the rest so we can check if the first - // segment is a version prefix. - let (first, rest) = path.split_once('/').unwrap_or((path, "")); - - // Check for a version prefix. - if let Some(v) = first.strip_prefix('v').and_then(|v| v.parse().ok()) { - let versions = &req.state().apis[&prefix]; - if let Some(api) = versions.get(&v) { - // The request has a valid version prefix, but did not match any route - // (hence this wildcard handler). Serve documentation for the intended - // API version. - let docs = html! { - "No route matches /" (rest) - br{} - (api.documentation()) - }; - return Ok(tide::Response::builder(StatusCode::NotFound) - .body(docs.into_string()) - .build()); - } else { - // The request has a version prefix for an unsupported version. List - // supported versions. - let docs = html! { - "Unsupported version v" (v) ". Supported versions are:" - ul { - @for v in versions.keys().rev() { - li { - a href=(format!("/{prefix}/v{v}")) { "v" (v) } - } - } - } - }; - return Ok(tide::Response::builder(StatusCode::NotImplemented) - .body(docs.into_string()) - .build()); - } - } - - // The request has no version prefix, redirect to the latest supported version. - Ok( - tide::Redirect::permanent(format!("/{prefix}/v{latest_version}/{path}")) - .into(), - ) - } - }); - Ok(()) } @@ -475,12 +395,12 @@ impl< #[allow(clippy::unnecessary_lazy_evaluations)] server .at("/public") - .at(prefix) .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!("{prefix}/v{version}")); + 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::>(); @@ -527,16 +447,39 @@ impl< } } - // Register automatic routes for this API: documentation, `healthcheck` and `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.all(move |req: tide::Request>| { - let prefix = prefix.clone(); - async move { - let api = &req.state().clone().apis[&prefix][&version]; - Ok(api.documentation()) - } - }); + 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(); @@ -702,6 +645,107 @@ 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 { @@ -1216,7 +1260,7 @@ mod test { assert_eq!( "deleted v1", client - .get("mod/v1/deleted") + .get("v1/mod/deleted") .send() .await .unwrap() @@ -1227,7 +1271,7 @@ mod test { assert_eq!( "unchanged v1", client - .get("mod/v1/unchanged") + .get("v1/mod/unchanged") .send() .await .unwrap() @@ -1243,7 +1287,7 @@ mod test { assert_eq!( "added v3", client - .get(&format!("mod{prefix}/added")) + .get(&format!("{prefix}/mod/added")) .send() .await .unwrap() @@ -1254,7 +1298,7 @@ mod test { assert_eq!( "unchanged v3", client - .get(&format!("mod{prefix}/unchanged")) + .get(&format!("{prefix}/mod/unchanged")) .send() .await .unwrap() @@ -1282,14 +1326,14 @@ mod test { let version = version.unwrap_or(3); let res = client - .get(&format!("mod{prefix}{route}")) + .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.contains(&format!("No route matches /{route}")), "{docs}" ); } @@ -1300,13 +1344,13 @@ mod test { } }; - for route in ["", "/deleted"] { + for route in ["", "deleted"] { check_docs(None, route).await; } - for route in ["", "/deleted"] { + for route in ["", "deleted"] { check_docs(Some(3), route).await; } - for route in ["", "/added"] { + for route in ["", "added"] { check_docs(Some(1), route).await; } @@ -1315,10 +1359,10 @@ mod test { "Unsupported version v2. Supported versions are:" ul { li { - a href="/mod/v3" {"v3"} + a href="/v3/mod" {"v3"} } li { - a href="/mod/v1" {"v1"} + a href="/v1/mod" {"v1"} } } } @@ -1328,7 +1372,7 @@ mod test { let _enter = span.enter(); tracing::info!("test unsupported version docs"); - let res = client.get(&format!("mod/v2{route}")).send().await.unwrap(); + let res = client.get(&format!("/v2/mod{route}")).send().await.unwrap(); let docs = res.text().await.unwrap(); assert_eq!(docs, expected_html); } @@ -1344,7 +1388,7 @@ mod test { None => "".into(), }; let res = client - .get(&format!("mod{prefix}/version")) + .get(&format!("{prefix}/mod/version")) .send() .await .unwrap(); @@ -1386,7 +1430,7 @@ mod test { None => "".into(), }; let res = client - .get(&format!("mod{prefix}/healthcheck")) + .get(&format!("{prefix}/mod/healthcheck")) .send() .await .unwrap(); @@ -1414,6 +1458,67 @@ mod test { ); } + #[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();