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
+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;