Skip to main content

gotify/
http.rs

1//! Shared HTTP plumbing: one pooled [`reqwest::Client`] per server, one place
2//! that maps transport and status-code failures to [`GotifyError`].
3//!
4//! [`GotifyClient`](crate::GotifyClient)'s every method calls
5//! [`request_json`](crate::http::request_json) with the same borrowed
6//! `Client` rather than each building their own — a fresh `reqwest::Client`
7//! per call defeats connection pooling and keep-alive under load, so build
8//! one with [`build_client`](crate::http::build_client) and reuse it for the
9//! client's lifetime.
10
11use std::time::Duration;
12
13use reqwest::{Client, Method, StatusCode};
14use serde_json::Value;
15
16use crate::error::{GotifyError, Result};
17use crate::GotifyConfig;
18
19/// Builds the pooled HTTP client used for every request against one server.
20///
21/// # Errors
22/// Returns [`GotifyError::ClientBuild`] if `reqwest` fails to construct the
23/// client.
24pub fn build_client(cfg: &GotifyConfig) -> Result<Client> {
25    reqwest::ClientBuilder::new()
26        .timeout(cfg.request_timeout)
27        .build()
28        .map_err(GotifyError::ClientBuild)
29}
30
31/// Issues one request against `base_url` using the caller-supplied `client`,
32/// and maps the transport/status outcome into a [`Result`].
33///
34/// `token`, when `Some`, is sent as the `X-Gotify-Key` header — `None` for
35/// the two unauthenticated endpoints (`health`, `version`). `action` is a
36/// friendly name (e.g. `"send_message"`) used only for the `tracing`
37/// span/logs this wraps the request in — every
38/// [`GotifyClient`](crate::GotifyClient) method funnels through this one
39/// function, so this is the one place instrumentation needs to live for it
40/// to cover all of them consistently.
41///
42/// # Errors
43/// Returns [`GotifyError::Timeout`] / [`GotifyError::Connect`] /
44/// [`GotifyError::Request`] for transport failures,
45/// [`GotifyError::Unauthorized`] / [`GotifyError::NotFound`] /
46/// [`GotifyError::RateLimited`] for the status codes Gotify uses for those
47/// conditions, and [`GotifyError::UnexpectedStatus`] for any other
48/// non-success status (its `body` is JSON when the response was JSON,
49/// otherwise the raw text). A `204 No Content` (used by Gotify's delete
50/// endpoints) returns a synthetic `{"status": "ok"}`. Otherwise returns
51/// [`GotifyError::Decode`] if the body isn't valid JSON.
52#[tracing::instrument(skip(client, token, query, body), fields(url))]
53#[allow(clippy::too_many_arguments)]
54pub async fn request_json(
55    client: &Client,
56    base_url: &str,
57    token: Option<&str>,
58    action: &str,
59    method: Method,
60    path: &str,
61    query: Option<&[(&str, String)]>,
62    body: Option<&Value>,
63) -> Result<Value> {
64    let url = format!(
65        "{}/{}",
66        base_url.trim_end_matches('/'),
67        path.trim_start_matches('/')
68    );
69    tracing::Span::current().record("url", tracing::field::display(&url));
70    let result = send_request(client, &url, token, method, query, body).await;
71    match &result {
72        Ok(value) => {
73            let count = value
74                .get("messages")
75                .or(Some(value))
76                .and_then(Value::as_array)
77                .map(Vec::len);
78            tracing::debug!(action, count, "upstream call ok");
79        }
80        Err(error) => tracing::warn!(action, %error, "upstream call failed"),
81    }
82    result
83}
84
85async fn send_request(
86    client: &Client,
87    url: &str,
88    token: Option<&str>,
89    method: Method,
90    query: Option<&[(&str, String)]>,
91    body: Option<&Value>,
92) -> Result<Value> {
93    let url = url.to_string();
94    let mut request = client.request(method.clone(), &url);
95    if let Some(token) = token {
96        request = request.header("X-Gotify-Key", token);
97    }
98    if let Some(query) = query {
99        request = request.query(query);
100    }
101    if let Some(body) = body {
102        request = request.json(body);
103    }
104
105    let response = request
106        .send()
107        .await
108        .map_err(|source| map_transport_error(&method, &url, source))?;
109    let status = response.status();
110
111    // Gotify's delete endpoints return 204 with no body.
112    if status.as_u16() == 204 {
113        return Ok(serde_json::json!({ "status": "ok" }));
114    }
115
116    match status {
117        StatusCode::UNAUTHORIZED => return Err(GotifyError::Unauthorized(url)),
118        StatusCode::NOT_FOUND => {
119            return Err(GotifyError::NotFound {
120                method: method.to_string(),
121                url,
122            })
123        }
124        StatusCode::TOO_MANY_REQUESTS => {
125            let retry_after = response
126                .headers()
127                .get(reqwest::header::RETRY_AFTER)
128                .and_then(|value| value.to_str().ok())
129                .and_then(|value| value.parse::<u64>().ok())
130                .map(Duration::from_secs);
131            return Err(GotifyError::RateLimited {
132                method: method.to_string(),
133                url,
134                retry_after,
135            });
136        }
137        _ => {}
138    }
139
140    let bytes = response
141        .bytes()
142        .await
143        .map_err(|source| map_transport_error(&method, &url, source))?;
144
145    // Check the status before doing anything with the body: a non-success
146    // response (500, a proxy's HTML error page, ...) is a status failure
147    // first and foremost. Parsing it strictly as JSON here would turn a
148    // perfectly diagnosable "HTTP 500" into an opaque `GotifyError::Decode`
149    // whenever the error body isn't JSON — which upstream error pages
150    // commonly aren't. Capture the body best-effort instead.
151    if !status.is_success() {
152        let body = if bytes.is_empty() {
153            Value::Null
154        } else {
155            serde_json::from_slice::<Value>(&bytes)
156                .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into_owned()))
157        };
158        return Err(GotifyError::UnexpectedStatus {
159            status: status.as_u16(),
160            url,
161            body: Box::new(body),
162        });
163    }
164
165    if bytes.is_empty() {
166        return Ok(serde_json::json!({ "status": "ok" }));
167    }
168    serde_json::from_slice::<Value>(&bytes).map_err(|source| GotifyError::Decode { url, source })
169}
170
171fn map_transport_error(method: &Method, url: &str, source: reqwest::Error) -> GotifyError {
172    if source.is_timeout() {
173        GotifyError::Timeout {
174            method: method.to_string(),
175            url: url.to_string(),
176            source,
177        }
178    } else if source.is_connect() {
179        GotifyError::Connect {
180            method: method.to_string(),
181            url: url.to_string(),
182            source,
183        }
184    } else {
185        GotifyError::Request {
186            method: method.to_string(),
187            url: url.to_string(),
188            source,
189        }
190    }
191}