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:
@@ -27,4 +27,7 @@ pub enum AppError {
|
||||
|
||||
#[error("storage error: {0}")]
|
||||
Storage(String),
|
||||
|
||||
#[error("gotify error: {0}")]
|
||||
Gotify(String),
|
||||
}
|
||||
@@ -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', " "))
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user