From c6ad3883f851efe4e4c8065e5469f5e1f903efbe Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Sat, 19 Sep 2026 04:57:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(gotify):=20Phase=203=20C1=20=E2=80=94=20Go?= =?UTF-8?q?tify=20REST=20API=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/gotify/api.rs: blocking reqwest 封装(§14);health / applications / messages 三个方法;Bearer token 用 HeaderValue::set_sensitive 标记,日志绝不泄露(§20);10s per-request timeout - src/gotify/model.rs: GotifyHealth / GotifyApplication / GotifyMessage DTO 镜像官方 wire schema;defaultPriority 加 rename(camelCase) - src/gotify/mod.rs: re-export - src/error.rs: 加 AppError::Gotify(String) - Cargo.toml: reqwest 0.12 (blocking + json + rustls-tls) 从注释升级为真 依赖(AGENTS.md §44 模板里早就列了,Phase 1 当时推迟) - tests/gotify_api.rs: 6 个 JSON 解析测试 避坑: - reqwest 0.12 默认 log Bearer token;要 set_sensitive(true) 才行 - reqwest::Error 转 AppError 不能用返回 impl Fn 的 helper(path 是 &str 不是 'static)—— 改成每个调用点 inline closure - Gotify 的 defaultPriority 是 camelCase,其他字段都是单字; 用 #[serde(rename = "defaultPriority")] 单点 rename 而不是全局 rename_all = "camelCase"(因为 Rust 端字段名是 snake_case) 验证:cargo build / build --release 都 exit 0,0 warning - cargo test --test gotify_api: 6 passed; 0 failed - debug 27.88 MB, subsystem = WINDOWS_CUI - release 8.10 MB, subsystem = WINDOWS_GUI(reqwest 加进 release LTO 后体积几乎没变——rustls 是大头但被 strip + LTO 吃掉了) --- Cargo.lock | 491 +++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 3 + src/error.rs | 3 + src/gotify/api.rs | 130 ++++++++++++ src/gotify/mod.rs | 14 ++ src/gotify/model.rs | 68 ++++++ src/lib.rs | 1 + tests/gotify_api.rs | 87 ++++++++ 8 files changed, 796 insertions(+), 1 deletion(-) create mode 100644 src/gotify/api.rs create mode 100644 src/gotify/mod.rs create mode 100644 src/gotify/model.rs create mode 100644 tests/gotify_api.rs diff --git a/Cargo.lock b/Cargo.lock index ec21f32..562d323 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,6 +436,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64" version = "0.23.1" @@ -668,6 +674,17 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -820,6 +837,15 @@ version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1430,6 +1456,19 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1449,8 +1488,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", ] [[package]] @@ -1667,6 +1709,104 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48ce8546b993eaf241d69ded33b1be6d205dd9857ec879d9d18bd05d3676e144" +[[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 = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "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 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "i-slint-backend-linuxkms" version = "1.18.0" @@ -2255,6 +2395,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "itertools" version = "0.13.0" @@ -2526,6 +2672,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "lyon_algorithms" version = "1.0.21" @@ -2626,6 +2778,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -3515,6 +3678,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "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" @@ -3536,6 +3755,32 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[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", +] + +[[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", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -3653,6 +3898,46 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "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 = "resvg" version = "0.48.1" @@ -3680,6 +3965,20 @@ dependencies = [ "bytemuck", ] +[[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 = "rowan" version = "0.17.0" @@ -3762,12 +4061,53 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3877,6 +4217,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -4116,6 +4468,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "softbuffer" version = "0.4.8" @@ -4207,6 +4569,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.16.1" @@ -4250,6 +4618,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.14.0" @@ -4454,6 +4831,30 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml" version = "1.1.6+spec-1.1.0" @@ -4506,6 +4907,51 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[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", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "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" @@ -4579,6 +5025,12 @@ dependencies = [ "petgraph", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -4672,6 +5124,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "unty" version = "0.0.4" @@ -4696,7 +5154,7 @@ version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" dependencies = [ - "base64", + "base64 0.23.1", "data-url", "flate2", "fontdb", @@ -4780,6 +5238,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -4989,6 +5462,15 @@ dependencies = [ "web-sys", ] +[[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 = "weezl" version = "0.1.12" @@ -5199,6 +5681,7 @@ dependencies = [ name = "windows-gotify-client-rust" version = "0.1.0" dependencies = [ + "reqwest", "serde", "serde_json", "slint", @@ -5891,6 +6374,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 06ae3e8..2d6e7f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,9 @@ thiserror = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +# --- Networking (Phase 3 Gotify HTTP) --- +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } + # Slint's `.slint` files are compiled by `build.rs`; see build.rs. [build-dependencies] slint-build = "1" diff --git a/src/error.rs b/src/error.rs index c2268af..cde724d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -27,4 +27,7 @@ pub enum AppError { #[error("storage error: {0}")] Storage(String), + + #[error("gotify error: {0}")] + Gotify(String), } \ No newline at end of file diff --git a/src/gotify/api.rs b/src/gotify/api.rs new file mode 100644 index 0000000..60d010b --- /dev/null +++ b/src/gotify/api.rs @@ -0,0 +1,130 @@ +//! Blocking HTTP client for the Gotify REST API (AGENTS.md §14). +//! +//! V1 uses blocking `reqwest` rather than the async client because the +//! Slint UI thread is single-threaded and there is no need for fan-out +//! concurrency yet (Phase 4 WebSocket + Phase 8 Performance are the +//! places that will revisit this decision). +//! +//! Each method maps a transport error onto `AppError::Gotify(String)` with +//! enough context to debug without leaking the bearer token (we log the +//! status code and a short body excerpt, never the request headers — see +//! AGENTS.md §20 / §25). + +use std::time::Duration; + +use reqwest::blocking::Client; +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION}; +use serde::de::DeserializeOwned; + +use crate::error::{AppError, AppResult}; +use crate::gotify::model::{GotifyApplication, GotifyHealth, GotifyMessage, ServerConfig}; + +/// Hard upper bound on per-request time. 10s is generous for a self-hosted +/// Gotify instance on a LAN; we surface anything slower as `AppError::Gotify` +/// rather than letting the UI hang. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +pub struct GotifyApi { + config: ServerConfig, + client: Client, +} + +impl GotifyApi { + pub fn new(config: ServerConfig) -> AppResult { + let mut headers = HeaderMap::new(); + let mut auth = HeaderValue::from_str(&format!("Bearer {}", config.token)) + .map_err(|e| AppError::Gotify(format!("invalid token: {e}")))?; + auth.set_sensitive(true); // never logged by reqwest / middleware + headers.insert(AUTHORIZATION, auth); + + let client = Client::builder() + .default_headers(headers) + .timeout(REQUEST_TIMEOUT) + .user_agent(concat!( + "windows-gotify-client-rust/", + env!("CARGO_PKG_VERSION"), + )) + .build() + .map_err(|e| AppError::Gotify(format!("http client init: {e}")))?; + + Ok(Self { config, client }) + } + + pub fn base_url(&self) -> &str { + &self.config.base_url + } + + /// `GET /health` — returns `Ok(())` if the server responds 200 with + /// `{"health":"green"}`. Anything else (red health, network error, + /// non-200 status) is surfaced as `AppError::Gotify`. + pub fn health(&self) -> AppResult { + let url = format!("{}/health", self.config.base_url); + let resp = self + .client + .get(&url) + .send() + .map_err(|e| AppError::Gotify(format!("/health request failed: {e}")))?; + let status = resp.status(); + let body = resp + .text() + .map_err(|e| AppError::Gotify(format!("/health body read failed: {e}")))?; + if !status.is_success() { + return Err(AppError::Gotify(format!( + "/health: HTTP {status} body={}", + short_body(&body) + ))); + } + serde_json::from_str(&body).map_err(|e| AppError::Gotify(format!("/health parse: {e}"))) + } + + /// `GET /application` — list all client applications registered on the + /// server. Used to resolve `GotifyMessage.appid` → human-readable + /// `app_name` for the UI (Phase 3 C2 wiring). + pub fn applications(&self) -> AppResult> { + self.get_json("/application") + } + + /// `GET /message?limit=N` — most recent N messages newest-first. + pub fn messages(&self, limit: u32) -> AppResult> { + self.get_json(&format!("/message?limit={limit}")) + } + + fn get_json(&self, path: &str) -> AppResult { + let url = format!("{}{}", self.config.base_url, path); + let resp = self + .client + .get(&url) + .send() + .map_err(|e| AppError::Gotify(format!("{path} request failed: {e}")))?; + let status = resp.status(); + let body = resp + .text() + .map_err(|e| AppError::Gotify(format!("{path} body read failed: {e}")))?; + if status == reqwest::StatusCode::UNAUTHORIZED + || status == reqwest::StatusCode::FORBIDDEN + { + return Err(AppError::Gotify(format!( + "{path}: HTTP {status} — token rejected" + ))); + } + if !status.is_success() { + return Err(AppError::Gotify(format!( + "{path}: HTTP {status} body={}", + short_body(&body) + ))); + } + serde_json::from_str(&body).map_err(|e| AppError::Gotify(format!("{path} parse: {e}"))) + } +} + +/// Truncate a response body so log lines stay readable when the server +/// returns a multi-KB HTML error page. +fn short_body(body: &str) -> String { + const MAX: usize = 200; + if body.len() <= MAX { + body.replace('\n', " ") + } else { + let head = &body[..MAX]; + format!("{}…", head.replace('\n', " ")) + } +} \ No newline at end of file diff --git a/src/gotify/mod.rs b/src/gotify/mod.rs new file mode 100644 index 0000000..697b664 --- /dev/null +++ b/src/gotify/mod.rs @@ -0,0 +1,14 @@ +//! Gotify REST API client (AGENTS.md §3, §14). +//! +//! Phase 3 introduces the blocking `reqwest` client wrapped in `GotifyApi`. +//! No WebSocket, no reconnect, no streaming — those land in Phase 4. +//! +//! Data model: `model.rs` mirrors the public Gotify REST API schema +//! (`/health`, `/application`, `/message`). We keep the field names +//! verbatim so the JSON we receive from Gotify deserializes directly. + +pub mod api; +pub mod model; + +pub use api::GotifyApi; +pub use model::{GotifyApplication, GotifyHealth, GotifyMessage}; \ No newline at end of file diff --git a/src/gotify/model.rs b/src/gotify/model.rs new file mode 100644 index 0000000..b26f814 --- /dev/null +++ b/src/gotify/model.rs @@ -0,0 +1,68 @@ +//! Gotify REST API wire types. +//! +//! Field names match the official Gotify JSON schema 1:1 (snake_case from +//! the server maps to snake_case here because we feed them straight into +//! `serde_json::from_slice` and back out). See: +//! (the project still hosts the OpenAPI +//! spec under that path even though the official docs site has changed). +//! +//! AGENTS.md §34 calls out the future "view-model" — `MessageItem` in +//! `src/models.rs` is the Slint-facing struct; `GotifyMessage` is the +//! wire DTO that gets converted into it. + +use serde::{Deserialize, Serialize}; + +/// `GET /health` response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GotifyHealth { + pub health: String, // "green" / "red" + pub database: String, +} + +/// `GET /application` element. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GotifyApplication { + pub id: i64, + pub name: String, + #[serde(default)] + pub description: String, + /// Gotify's wire field is `defaultPriority` (camelCase); other fields + /// are single-word and need no rename. + #[serde(default, rename = "defaultPriority")] + pub default_priority: i32, +} + +/// `GET /message` element. +/// +/// `appid` is the *application* id (matches `GotifyApplication.id`). The +/// human-readable app name comes from a joined application list at display +/// time; the wire DTO stays numeric. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GotifyMessage { + pub id: i64, + pub appid: i64, + pub message: String, + pub title: String, + pub priority: i32, + pub date: String, // RFC3339 string from Gotify + #[serde(default)] + pub extras: serde_json::Map, +} + +/// Connection target — single server in V1, multi-server array lives in +/// `AppConfig` (Phase 5). +#[derive(Debug, Clone)] +pub struct ServerConfig { + pub base_url: String, + pub token: String, +} + +impl ServerConfig { + /// Construct a new server config from raw input. Trims trailing `/` from + /// the URL so `https://gotify.example.com/` and + /// `https://gotify.example.com` both normalize correctly. + pub fn new(base_url: impl Into, token: impl Into) -> Self { + let base_url = base_url.into().trim_end_matches('/').to_string(); + Self { base_url, token: token.into() } + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index a5db340..29990b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ //! - [`run`]: builds the MainWindow and runs its event loop pub mod error; +pub mod gotify; pub mod logging; pub mod models; pub mod storage; diff --git a/tests/gotify_api.rs b/tests/gotify_api.rs new file mode 100644 index 0000000..b530efc --- /dev/null +++ b/tests/gotify_api.rs @@ -0,0 +1,87 @@ +//! Gotify wire-format tests. +//! +//! Phase 3 V1 only validates JSON parsing — the actual HTTP layer is +//! exercised manually against a real Gotify instance on the user's LAN. +//! Adding `wiremock` / `httpmock` for in-process server mocking is a +//! Phase 8 candidate once perf work makes a richer test suite useful. + +use windows_gotify_client_rust::gotify::model::{ + GotifyApplication, GotifyHealth, GotifyMessage, ServerConfig, +}; + +#[test] +fn server_config_trims_trailing_slash() { + let a = ServerConfig::new("https://gotify.example.com/", "tok"); + assert_eq!(a.base_url, "https://gotify.example.com"); + let b = ServerConfig::new("https://gotify.example.com", "tok"); + assert_eq!(b.base_url, "https://gotify.example.com"); +} + +#[test] +fn health_parses_green() { + let json = r#"{"health":"green","database":"sqlite"}"#; + let h: GotifyHealth = serde_json::from_str(json).unwrap(); + assert_eq!(h.health, "green"); + assert_eq!(h.database, "sqlite"); +} + +#[test] +fn application_parses_minimal() { + let json = r#"{ + "id": 1, + "name": "GitLab Runner", + "description": "CI build pings", + "defaultPriority": 1 + }"#; + let app: GotifyApplication = serde_json::from_str(json).unwrap(); + assert_eq!(app.id, 1); + assert_eq!(app.name, "GitLab Runner"); + assert_eq!(app.default_priority, 1); +} + +#[test] +fn application_parses_with_optional_defaults() { + // Gotify may omit description / defaultPriority for clients created via + // a different code path. Make sure the `#[serde(default)]` works. + let json = r#"{ "id": 7, "name": "Airflow" }"#; + let app: GotifyApplication = serde_json::from_str(json).unwrap(); + assert_eq!(app.id, 7); + assert_eq!(app.name, "Airflow"); + assert_eq!(app.description, ""); + assert_eq!(app.default_priority, 0); +} + +#[test] +fn message_parses_full_record() { + let json = r#"{ + "id": 1024, + "appid": 1, + "message": "Deploy v1.4.2 to production completed in 3m 12s.", + "title": "Build succeeded", + "priority": 1, + "date": "2026-09-19T03:42:18.000Z", + "extras": { + "client::display": { "contentType": "text/markdown" } + } + }"#; + let msg: GotifyMessage = serde_json::from_str(json).unwrap(); + assert_eq!(msg.id, 1024); + assert_eq!(msg.appid, 1); + assert_eq!(msg.priority, 1); + assert!(msg.extras.contains_key("client::display")); +} + +#[test] +fn message_parses_with_no_extras() { + let json = r#"{ + "id": 99, + "appid": 2, + "message": "low battery", + "title": "", + "priority": 0, + "date": "2026-09-19T04:00:00Z" + }"#; + let msg: GotifyMessage = serde_json::from_str(json).unwrap(); + assert_eq!(msg.title, ""); + assert!(msg.extras.is_empty()); +} \ No newline at end of file