From 2de7f88e42410a7032a6087b239c93d6dd56c118 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 19:55:31 +0100 Subject: [PATCH] feat(api): axum skeleton with health and OpenAPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document is collected from `#[utoipa::path]` annotations through `utoipa_axum::routes!`, which refuses a handler that has none, so an unannotated route fails the build rather than silently leaving the TypeScript client short an endpoint. Health probes the three upstreams DESIGN.md §9.5 calls "Broken", always answering 200 so a degraded service can still explain itself. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/ci.yml | 2 +- .gitignore | 2 + Cargo.lock | 957 +++++++++++++++++++++++++++++++++- Justfile | 18 +- crates/arr-api/Cargo.toml | 11 + crates/arr-api/src/health.rs | 183 +++++++ crates/arr-api/src/lib.rs | 270 +++++++++- crates/arr-api/src/state.rs | 89 ++++ crates/arr-daemon/Cargo.toml | 15 +- crates/arr-daemon/src/main.rs | 112 +++- 10 files changed, 1626 insertions(+), 33 deletions(-) create mode 100644 crates/arr-api/src/health.rs create mode 100644 crates/arr-api/src/state.rs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8691b4c..8f8a202 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -73,7 +73,7 @@ jobs: - name: Skip when the frontend does not exist yet id: probe run: | - if [ -d web ]; then + if [ -f web/package.json ]; then echo "present=true" >> "$GITHUB_OUTPUT" else echo "present=false" >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index 76e21cc..1884ec6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /target +# Generated by `just gen-client`, never edited and never reviewed. +/web/src/api /web/node_modules /web/dist .env diff --git a/Cargo.lock b/Cargo.lock index 34fb6b5..b7f8720 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -20,6 +29,17 @@ dependencies = [ [[package]] name = "arr-api" version = "0.1.0" +dependencies = [ + "axum", + "reqwest", + "serde", + "serde_json", + "tokio", + "utoipa", + "utoipa-axum", + "utoipa-scalar", + "wiremock", +] [[package]] name = "arr-compat" @@ -33,10 +53,17 @@ version = "0.1.0" name = "arr-daemon" version = "0.1.0" dependencies = [ + "arr-api", + "axum", + "reqwest", "serde", "tempfile", "thiserror", + "tokio", "toml", + "tower-http", + "tracing", + "tracing-subscriber", ] [[package]] @@ -72,6 +99,16 @@ version = "0.1.0" name = "arr-probe" version = "0.1.0" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "atoi" version = "2.0.0" @@ -81,12 +118,70 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -151,6 +246,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -183,6 +295,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -223,6 +344,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "der" version = "0.7.10" @@ -332,6 +471,12 @@ dependencies = [ "spin", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -347,6 +492,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -391,6 +551,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "futures-sink" version = "0.3.34" @@ -409,8 +580,10 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -435,8 +608,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -446,8 +621,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] @@ -482,6 +679,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -515,6 +718,112 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -651,8 +960,16 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + [[package]] name = "itoa" version = "1.0.18" @@ -741,6 +1058,27 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md-5" version = "0.10.6" @@ -757,6 +1095,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.2.2" @@ -768,6 +1112,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -779,7 +1132,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -813,6 +1166,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -848,6 +1211,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -929,6 +1298,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -952,7 +1377,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -962,7 +1398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -974,6 +1410,21 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -992,6 +1443,87 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsa" version = "0.9.10" @@ -1005,13 +1537,19 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", "zeroize", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustix" version = "1.1.4" @@ -1025,6 +1563,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1086,6 +1659,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -1114,7 +1698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1125,10 +1709,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1152,7 +1745,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1315,7 +1908,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.7", "rsa", "serde", "sha1", @@ -1354,7 +1947,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.7", "serde", "serde_json", "sha2", @@ -1436,6 +2029,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1480,6 +2082,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -1532,6 +2143,16 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -1543,6 +2164,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -1584,6 +2219,53 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1614,8 +2296,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", ] +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -1649,6 +2367,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1667,6 +2391,61 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-axum" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c25bae5bccc842449ec0c5ddc5cbb6a3a1eaeac4503895dc105a1138f8234a0" +dependencies = [ + "axum", + "paste", + "tower-layer", + "tower-service", + "utoipa", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.119", +] + +[[package]] +name = "utoipa-scalar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719" +dependencies = [ + "axum", + "serde", + "serde_json", + "utoipa", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -1679,6 +2458,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1704,6 +2492,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.127" @@ -1736,6 +2534,35 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.6.1" @@ -1811,7 +2638,16 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -1829,13 +2665,29 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -1844,42 +2696,90 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -1889,6 +2789,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/Justfile b/Justfile index 95a436e..f8b2d6b 100644 --- a/Justfile +++ b/Justfile @@ -31,11 +31,13 @@ test: e2e: cargo nextest run -p arr-e2e -# Frontend lint and typecheck. No-op until web/ exists (issue #6). +# Frontend lint and typecheck. No-op until the SPA exists (issue #6). Probes +# for package.json, not the directory: `just gen-client` writes into web/ and +# must not turn the gate on before there is a project to check. web-check: #!/usr/bin/env bash set -euo pipefail - if [ ! -d web ]; then echo "web/ not present yet, skipping"; exit 0; fi + if [ ! -f web/package.json ]; then echo "web/ not present yet, skipping"; exit 0; fi biome ci web/ pnpm -C web install --frozen-lockfile pnpm -C web exec tsc -b --noEmit @@ -44,12 +46,22 @@ web-check: build: #!/usr/bin/env bash set -euo pipefail - if [ -d web ]; then + if [ -f web/package.json ]; then pnpm -C web install --frozen-lockfile pnpm -C web build fi cargo build --release +# Regenerate the TypeScript client from the OpenAPI document (DESIGN.md §9.1). +# The document is dumped from the binary, so nothing has to be running. Both +# outputs are generated: never edit them, and never hand-write the spec. +gen-client: + #!/usr/bin/env bash + set -euo pipefail + mkdir -p web/src/api + cargo run --quiet -p arr-daemon --bin arr -- --openapi > web/src/api/openapi.json + pnpm dlx openapi-typescript@7 web/src/api/openapi.json -o web/src/api/schema.d.ts + # Recreate the development database from an empty file. db-reset: #!/usr/bin/env bash diff --git a/crates/arr-api/Cargo.toml b/crates/arr-api/Cargo.toml index 984c7d2..eb05a31 100644 --- a/crates/arr-api/Cargo.toml +++ b/crates/arr-api/Cargo.toml @@ -7,6 +7,17 @@ repository.workspace = true publish = false [dependencies] +axum = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +utoipa = { workspace = true } +utoipa-axum = { workspace = true } +utoipa-scalar = { workspace = true } + +[dev-dependencies] +wiremock = { workspace = true } [lints] workspace = true diff --git a/crates/arr-api/src/health.rs b/crates/arr-api/src/health.rs new file mode 100644 index 0000000..53832eb --- /dev/null +++ b/crates/arr-api/src/health.rs @@ -0,0 +1,183 @@ +//! `GET /api/health` — is each of the three upstreams answering. +//! +//! The three probed here are the ones DESIGN.md §9.5 calls "Broken": without +//! Prowlarr nothing is found, without Transmission nothing is fetched, and +//! without TMDB nothing is identified. The endpoint always answers `200` — +//! the body carries the verdict, so a degraded service can still explain +//! itself to the UI instead of looking like a fourth outage. + +use axum::extract::State; +use axum::Json; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::state::AppState; + +/// Whether the service as a whole can do its job. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Health { + /// Every upstream answered. + Ok, + /// At least one upstream is unreachable or unconfigured. + Degraded, +} + +/// The verdict for a single upstream. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Status { + /// Answered as expected. + Ok, + /// Did not answer, or answered with an unexpected status. + Unreachable, + /// No API key configured, so it was not probed. + Unconfigured, +} + +/// One upstream's result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)] +pub struct Check { + pub status: Status, + /// Why, when the status is not `ok`. Never contains the probed URL: it + /// can carry an API key in the query string. + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +impl Check { + fn ok() -> Self { + Self { + status: Status::Ok, + detail: None, + } + } + + fn unreachable(detail: impl Into) -> Self { + Self { + status: Status::Unreachable, + detail: Some(detail.into()), + } + } + + fn unconfigured(detail: impl Into) -> Self { + Self { + status: Status::Unconfigured, + detail: Some(detail.into()), + } + } +} + +/// The body of `GET /api/health`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)] +pub struct HealthReport { + pub status: Health, + /// The running binary's version. + #[schema(example = "0.1.0")] + pub version: String, + pub prowlarr: Check, + pub transmission: Check, + pub tmdb: Check, +} + +/// Report reachability of Prowlarr, Transmission and TMDB. +#[utoipa::path( + get, + path = "/api/health", + tag = "system", + responses( + (status = 200, description = "Per-upstream reachability", body = HealthReport), + ), +)] +pub async fn health(State(state): State) -> Json { + // Three independent network probes; serialising them would make the + // endpoint as slow as the sum of the timeouts. + let (prowlarr, transmission, tmdb) = tokio::join!( + probe_prowlarr(&state), + probe_transmission(&state), + probe_tmdb(&state), + ); + + let status = if [prowlarr.status, transmission.status, tmdb.status] + .iter() + .all(|s| *s == Status::Ok) + { + Health::Ok + } else { + Health::Degraded + }; + + Json(HealthReport { + status, + version: env!("CARGO_PKG_VERSION").to_string(), + prowlarr, + transmission, + tmdb, + }) +} + +/// Prowlarr answers `/ping` without a key; the key is sent anyway so a +/// misconfigured one shows up here rather than at the first search. +async fn probe_prowlarr(state: &AppState) -> Check { + let url = format!( + "{}/ping", + state.upstreams().prowlarr_url.trim_end_matches('/') + ); + let mut request = state.http().get(url); + if let Some(key) = &state.upstreams().prowlarr_api_key { + request = request.header("X-Api-Key", key); + } + match request.send().await { + Ok(response) if response.status().is_success() => Check::ok(), + Ok(response) => Check::unreachable(format!("http {}", response.status().as_u16())), + Err(err) => Check::unreachable(describe(err)), + } +} + +/// Transmission answers an RPC call without a session id with `409` plus the +/// id to retry with. That is a live daemon, so it counts as reachable. +async fn probe_transmission(state: &AppState) -> Check { + let request = state + .http() + .post(&state.upstreams().transmission_url) + .json(&serde_json::json!({ "method": "session-get" })); + match request.send().await { + Ok(response) + if response.status().is_success() + || response.status() == reqwest::StatusCode::CONFLICT => + { + Check::ok() + } + Ok(response) => Check::unreachable(format!("http {}", response.status().as_u16())), + Err(err) => Check::unreachable(describe(err)), + } +} + +/// TMDB is the only upstream that cannot be probed at all without a key, so +/// a missing key is reported as its own state rather than as an outage. +async fn probe_tmdb(state: &AppState) -> Check { + let Some(key) = &state.upstreams().tmdb_api_key else { + return Check::unconfigured("no ARR_TMDB_API_KEY set"); + }; + let url = format!( + "{}/configuration", + state.upstreams().tmdb_url.trim_end_matches('/') + ); + match state + .http() + .get(url) + .query(&[("api_key", key)]) + .send() + .await + { + Ok(response) if response.status().is_success() => Check::ok(), + Ok(response) => Check::unreachable(format!("http {}", response.status().as_u16())), + Err(err) => Check::unreachable(describe(err)), + } +} + +/// `reqwest`'s own `Display` includes the URL, and the TMDB URL carries the +/// API key. `without_url` is what keeps the key out of the response body. +fn describe(err: reqwest::Error) -> String { + err.without_url().to_string() +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 8b38675..bf1999d 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -1 +1,269 @@ -//! arr-api — see DESIGN.md. +//! arr-api — the HTTP surface. See DESIGN.md §9.1. +//! +//! The API is the product; the web UI is one client of it. So the `OpenAPI` +//! document is not written by hand and not kept in step by review: routes are +//! registered through [`utoipa_axum::routes`], which only accepts a handler +//! carrying a `#[utoipa::path]` annotation. A handler added without one fails +//! to compile, and the gate in DESIGN.md §12 fails with it. + +mod health; +mod state; + +use axum::routing::get; +use axum::{Json, Router}; +use utoipa::OpenApi; +use utoipa_axum::router::OpenApiRouter; +use utoipa_axum::routes; +use utoipa_scalar::{Scalar, Servable}; + +pub use health::{Check, Health, HealthReport, Status}; +pub use state::{AppState, Upstreams, DEFAULT_TMDB_URL}; + +/// Where the generated document is served, and where `just gen-client` reads +/// it back from when it is fetched rather than dumped from the binary. +pub const OPENAPI_PATH: &str = "/api/openapi.json"; + +/// Where the browsable UI lives. +pub const DOCS_PATH: &str = "/api/docs"; + +/// Document-level metadata. Paths and schemas are collected from the router, +/// never listed here — a list is a thing to forget to update. +#[derive(OpenApi)] +#[openapi( + info( + title = "arr", + description = "One service in place of Radarr and Sonarr. No authentication: \ + the perimeter is the VPN (DESIGN.md §2).", + ), + tags((name = "system", description = "Service health and metadata")), +)] +struct ApiDoc; + +/// Every annotated route, still needing state. +fn api_router() -> OpenApiRouter { + OpenApiRouter::with_openapi(ApiDoc::openapi()).routes(routes!(health::health)) +} + +/// The generated `OpenAPI` document. +#[must_use] +pub fn openapi() -> utoipa::openapi::OpenApi { + api_router().split_for_parts().1 +} + +/// The whole application: the API, the served document, and the browsable UI. +pub fn router(state: AppState) -> Router { + let (router, api) = api_router().split_for_parts(); + let document = api.clone(); + + router + .route( + OPENAPI_PATH, + get(move || { + let document = document.clone(); + async move { Json(document) } + }), + ) + .merge(Scalar::with_url(DOCS_PATH, api)) + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + /// A Prowlarr that answers `/ping`, and a Transmission that answers an + /// RPC call the way a real one does when it has no session id yet. + async fn upstreams_up() -> (MockServer, MockServer) { + let prowlarr = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/ping")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "OK" })), + ) + .mount(&prowlarr) + .await; + + let transmission = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/transmission/rpc")) + .respond_with( + ResponseTemplate::new(409).insert_header("X-Transmission-Session-Id", "abc"), + ) + .mount(&transmission) + .await; + + (prowlarr, transmission) + } + + /// Serve the app on an ephemeral port and return its base URL. The server + /// task dies with the runtime at the end of the test. + async fn serve(state: AppState) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let addr = listener.local_addr().expect("local addr"); + tokio::spawn(async move { + axum::serve(listener, router(state)).await.expect("serve"); + }); + format!("http://{addr}") + } + + async fn report(state: AppState) -> serde_json::Value { + let base = serve(state).await; + let response = reqwest::get(format!("{base}/api/health")) + .await + .expect("request health"); + assert_eq!(response.status(), 200, "health always answers 200"); + response.json().await.expect("health body is json") + } + + #[tokio::test] + async fn all_upstreams_up_is_ok() { + let (prowlarr, transmission) = upstreams_up().await; + let tmdb = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/configuration")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .mount(&tmdb) + .await; + + let state = AppState::new( + Upstreams::new( + prowlarr.uri(), + format!("{}/transmission/rpc", transmission.uri()), + ) + .with_tmdb_url(tmdb.uri()) + .with_tmdb_api_key(Some("key".into())), + ) + .expect("state"); + + let body = report(state).await; + assert_eq!(body["status"], "ok"); + assert_eq!(body["prowlarr"]["status"], "ok"); + assert_eq!(body["transmission"]["status"], "ok"); + assert_eq!(body["tmdb"]["status"], "ok"); + assert_eq!(body["version"], env!("CARGO_PKG_VERSION")); + } + + #[tokio::test] + async fn a_missing_tmdb_key_is_unconfigured_not_an_outage() { + let (prowlarr, transmission) = upstreams_up().await; + let state = AppState::new(Upstreams::new( + prowlarr.uri(), + format!("{}/transmission/rpc", transmission.uri()), + )) + .expect("state"); + + let body = report(state).await; + assert_eq!(body["tmdb"]["status"], "unconfigured"); + assert_eq!(body["status"], "degraded"); + } + + #[tokio::test] + async fn an_unreachable_upstream_degrades_the_service() { + let (_prowlarr, transmission) = upstreams_up().await; + + // Port 1 is privileged and nothing binds it, so the probe gets a + // refused connection immediately instead of waiting out the timeout. + let state = AppState::new(Upstreams::new( + "http://127.0.0.1:1".into(), + format!("{}/transmission/rpc", transmission.uri()), + )) + .expect("state"); + + let body = report(state).await; + assert_eq!(body["status"], "degraded"); + assert_eq!(body["prowlarr"]["status"], "unreachable"); + assert_eq!(body["transmission"]["status"], "ok"); + } + + #[tokio::test] + async fn an_upstream_answering_wrongly_is_unreachable() { + let prowlarr = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/ping")) + .respond_with(ResponseTemplate::new(500)) + .mount(&prowlarr) + .await; + let transmission = MockServer::start().await; + + let state = AppState::new(Upstreams::new( + prowlarr.uri(), + format!("{}/transmission/rpc", transmission.uri()), + )) + .expect("state"); + + let body = report(state).await; + assert_eq!(body["prowlarr"]["status"], "unreachable"); + assert_eq!(body["prowlarr"]["detail"], "http 500"); + } + + #[tokio::test] + async fn a_failed_tmdb_probe_never_echoes_the_api_key() { + let (prowlarr, transmission) = upstreams_up().await; + let tmdb = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/configuration")) + .respond_with(ResponseTemplate::new(401)) + .mount(&tmdb) + .await; + + let state = AppState::new( + Upstreams::new( + prowlarr.uri(), + format!("{}/transmission/rpc", transmission.uri()), + ) + .with_tmdb_url(tmdb.uri()) + .with_tmdb_api_key(Some("super-secret".into())), + ) + .expect("state"); + + let body = report(state).await; + assert_eq!(body["tmdb"]["status"], "unreachable"); + assert!( + !body.to_string().contains("super-secret"), + "the key must not reach the response body: {body}" + ); + } + + #[test] + fn the_document_is_generated_from_the_handler() { + let document = openapi(); + let json = serde_json::to_value(&document).expect("serialise document"); + + assert!( + json["paths"]["/api/health"]["get"].is_object(), + "the health route registered itself: {json}" + ); + assert_eq!(json["paths"]["/api/health"]["get"]["tags"][0], "system"); + assert!( + json["components"]["schemas"]["HealthReport"].is_object(), + "the response body schema came along with it: {json}" + ); + } + + #[tokio::test] + async fn the_document_and_the_ui_are_served() { + let state = AppState::new(Upstreams::new( + "http://127.0.0.1:1".into(), + "http://127.0.0.1:1".into(), + )) + .expect("state"); + let base = serve(state).await; + + let document: serde_json::Value = reqwest::get(format!("{base}{OPENAPI_PATH}")) + .await + .expect("fetch document") + .json() + .await + .expect("document is json"); + assert!(document["paths"]["/api/health"].is_object()); + + let docs = reqwest::get(format!("{base}{DOCS_PATH}")) + .await + .expect("fetch docs"); + assert_eq!(docs.status(), 200); + } +} diff --git a/crates/arr-api/src/state.rs b/crates/arr-api/src/state.rs new file mode 100644 index 0000000..ec53a87 --- /dev/null +++ b/crates/arr-api/src/state.rs @@ -0,0 +1,89 @@ +//! What the API needs to answer a request: one HTTP client and the addresses +//! of the three upstreams the service cannot work without (DESIGN.md §3). + +use std::sync::Arc; +use std::time::Duration; + +/// The TMDB API root. Not a bootstrap setting (DESIGN.md §10) — only the key +/// is configurable, so this is a constant that tests point elsewhere. +pub const DEFAULT_TMDB_URL: &str = "https://api.themoviedb.org/3"; + +/// How long an upstream has to answer a probe before it counts as +/// unreachable. Health is polled by a human waiting on a page. +const PROBE_TIMEOUT: Duration = Duration::from_secs(3); + +/// Where the upstreams live, and the keys for the two that need one. +#[derive(Debug, Clone)] +pub struct Upstreams { + pub prowlarr_url: String, + pub prowlarr_api_key: Option, + pub transmission_url: String, + pub tmdb_url: String, + pub tmdb_api_key: Option, +} + +impl Upstreams { + /// Every upstream at its documented default, no keys. + #[must_use] + pub fn new(prowlarr_url: String, transmission_url: String) -> Self { + Self { + prowlarr_url, + prowlarr_api_key: None, + transmission_url, + tmdb_url: DEFAULT_TMDB_URL.to_string(), + tmdb_api_key: None, + } + } + + /// Set the Prowlarr API key. + #[must_use] + pub fn with_prowlarr_api_key(mut self, key: Option) -> Self { + self.prowlarr_api_key = key; + self + } + + /// Set the TMDB API key. + #[must_use] + pub fn with_tmdb_api_key(mut self, key: Option) -> Self { + self.tmdb_api_key = key; + self + } + + /// Point TMDB somewhere other than the real API. Tests only. + #[must_use] + pub fn with_tmdb_url(mut self, url: String) -> Self { + self.tmdb_url = url; + self + } +} + +/// Shared handler state. Cheap to clone: the client pools internally and the +/// upstream addresses are behind an [`Arc`]. +#[derive(Debug, Clone)] +pub struct AppState { + http: reqwest::Client, + upstreams: Arc, +} + +impl AppState { + /// Build the state, including the shared HTTP client. + /// + /// # Errors + /// + /// If the TLS backend cannot be initialised. + pub fn new(upstreams: Upstreams) -> Result { + let http = reqwest::Client::builder().timeout(PROBE_TIMEOUT).build()?; + Ok(Self { + http, + upstreams: Arc::new(upstreams), + }) + } + + pub(crate) fn http(&self) -> &reqwest::Client { + &self.http + } + + pub(crate) fn upstreams(&self) -> &Upstreams { + &self.upstreams + } +} diff --git a/crates/arr-daemon/Cargo.toml b/crates/arr-daemon/Cargo.toml index 98665ed..8ecd7b1 100644 --- a/crates/arr-daemon/Cargo.toml +++ b/crates/arr-daemon/Cargo.toml @@ -11,12 +11,19 @@ name = "arr" path = "src/main.rs" [dependencies] -serde.workspace = true -thiserror.workspace = true -toml.workspace = true +arr-api = { workspace = true } +axum = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +toml = { workspace = true } +tower-http = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } [dev-dependencies] -tempfile.workspace = true +tempfile = { workspace = true } [lints] workspace = true diff --git a/crates/arr-daemon/src/main.rs b/crates/arr-daemon/src/main.rs index c7b5fdf..4774b65 100644 --- a/crates/arr-daemon/src/main.rs +++ b/crates/arr-daemon/src/main.rs @@ -4,15 +4,113 @@ mod config; use std::process::ExitCode; -fn main() -> ExitCode { - match config::Config::load() { - Ok(config) => { - println!("arr starting, bind_addr={}", config.bind_addr); - ExitCode::SUCCESS - } +use arr_api::{AppState, Upstreams}; +use config::Config; +use tower_http::trace::TraceLayer; + +/// Dump the `OpenAPI` document and exit, instead of serving. `just gen-client` +/// uses this so the TypeScript client can be regenerated without a port or a +/// single upstream being up. +const OPENAPI_FLAG: &str = "--openapi"; + +#[tokio::main] +async fn main() -> ExitCode { + if std::env::args().nth(1).as_deref() == Some(OPENAPI_FLAG) { + return dump_openapi(); + } + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,tower_http=debug".into()), + ) + .init(); + + match run().await { + Ok(()) => ExitCode::SUCCESS, Err(err) => { - eprintln!("arr: {err}"); + tracing::error!("{err}"); ExitCode::FAILURE } } } + +fn dump_openapi() -> ExitCode { + match arr_api::openapi().to_pretty_json() { + Ok(json) => { + println!("{json}"); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("arr: openapi: {err}"); + ExitCode::FAILURE + } + } +} + +#[derive(Debug, thiserror::Error)] +enum Error { + #[error("config: {0}")] + Config(#[from] config::ConfigError), + #[error("http client: {0}")] + HttpClient(#[from] reqwest::Error), + #[error("bind {addr}: {source}")] + Bind { + addr: std::net::SocketAddr, + source: std::io::Error, + }, + #[error("serve: {0}")] + Serve(std::io::Error), +} + +async fn run() -> Result<(), Error> { + let config = Config::load()?; + + let state = AppState::new( + Upstreams::new(config.prowlarr_url, config.transmission_url) + .with_prowlarr_api_key(config.prowlarr_api_key) + .with_tmdb_api_key(config.tmdb_api_key), + )?; + + let app = arr_api::router(state).layer(TraceLayer::new_for_http()); + + let listener = tokio::net::TcpListener::bind(config.bind_addr) + .await + .map_err(|source| Error::Bind { + addr: config.bind_addr, + source, + })?; + + tracing::info!(addr = %config.bind_addr, docs = arr_api::DOCS_PATH, "listening"); + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown()) + .await + .map_err(Error::Serve) +} + +/// Stop accepting on Ctrl-C, or on the SIGTERM a service manager sends. +async fn shutdown() { + let interrupt = async { + let _ = tokio::signal::ctrl_c().await; + }; + + #[cfg(unix)] + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + signal.recv().await; + } + Err(err) => tracing::warn!("no SIGTERM handler: {err}"), + } + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + () = interrupt => {}, + () = terminate => {}, + } + + tracing::info!("shutting down"); +} -- 2.47.3