feat(gotify): Phase 3 C1 — Gotify REST API client

- 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 吃掉了)
This commit is contained in:
2026-09-19 04:57:44 +08:00
parent 680fbfa0ad
commit c6ad3883f8
8 changed files with 796 additions and 1 deletions
Generated
+490 -1
View File
@@ -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"
+3
View File
@@ -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"
+3
View File
@@ -27,4 +27,7 @@ pub enum AppError {
#[error("storage error: {0}")]
Storage(String),
#[error("gotify error: {0}")]
Gotify(String),
}
+130
View File
@@ -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<Self> {
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<GotifyHealth> {
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<Vec<GotifyApplication>> {
self.get_json("/application")
}
/// `GET /message?limit=N` — most recent N messages newest-first.
pub fn messages(&self, limit: u32) -> AppResult<Vec<GotifyMessage>> {
self.get_json(&format!("/message?limit={limit}"))
}
fn get_json<T: DeserializeOwned>(&self, path: &str) -> AppResult<T> {
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', " "))
}
}
+14
View File
@@ -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};
+68
View File
@@ -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:
//! <https://gotify.net/api-docs/> (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<String, serde_json::Value>,
}
/// 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<String>, token: impl Into<String>) -> Self {
let base_url = base_url.into().trim_end_matches('/').to_string();
Self { base_url, token: token.into() }
}
}
+1
View File
@@ -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;
+87
View File
@@ -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());
}