Skip to main content

dsc/api/
client.rs

1use super::models::{AboutResponse, SiteResponse};
2use super::rate_limit::{
3    RETRY_BUFFER, parse_rate_limit_wait, summarize_rate_limit_body,
4};
5use crate::config::DiscourseConfig;
6use crate::utils::normalize_baseurl;
7use anyhow::{Context, Result, anyhow};
8use reqwest::StatusCode;
9use reqwest::blocking::{Client, RequestBuilder, Response};
10use reqwest::header::{HeaderMap, HeaderValue};
11
12const MAX_RATE_LIMIT_RETRIES: u32 = 5;
13
14#[derive(Debug, Clone)]
15pub struct VersionInfo {
16    pub version: Option<String>,
17    pub commit: Option<String>,
18}
19
20/// HTTP client for the Discourse API.
21#[derive(Clone)]
22pub struct DiscourseClient {
23    baseurl: String,
24    client: Client,
25}
26
27impl DiscourseClient {
28    /// Create a new Discourse API client.
29    pub fn new(config: &DiscourseConfig) -> Result<Self> {
30        let baseurl = normalize_baseurl(&config.baseurl);
31        if baseurl.is_empty() {
32            return Err(anyhow!(
33                "missing baseurl for discourse {}; please set baseurl or check your config",
34                config.name
35            ));
36        }
37
38        let mut headers = HeaderMap::new();
39        if let (Some(apikey), Some(api_username)) =
40            (config.apikey.as_ref(), config.api_username.as_ref())
41        {
42            headers.insert(
43                "Api-Key",
44                HeaderValue::from_str(apikey).context("invalid api key")?,
45            );
46            headers.insert(
47                "Api-Username",
48                HeaderValue::from_str(api_username).context("invalid api username")?,
49            );
50        }
51
52        let client = Client::builder()
53            .default_headers(headers)
54            .build()
55            .context("building http client")?;
56
57        Ok(Self { baseurl, client })
58    }
59
60    /// Return the configured base URL.
61    pub fn baseurl(&self) -> &str {
62        &self.baseurl
63    }
64
65    pub(crate) fn get(&self, path: &str) -> Result<Response> {
66        let url = format!("{}{}", self.baseurl, path);
67        // Route GETs through `send_retrying` so a 429 response (whether
68        // from Discourse's app-level rate limit or a fronting nginx)
69        // is retried automatically. Crucial for the analytics command
70        // which can fan out tens of GETs back-to-back.
71        self.send_retrying(|| Ok(self.client.get(&url)))
72    }
73
74    pub(crate) fn post(&self, path: &str) -> Result<reqwest::blocking::RequestBuilder> {
75        let url = format!("{}{}", self.baseurl, path);
76        Ok(self.client.post(url))
77    }
78
79    pub(crate) fn put(&self, path: &str) -> Result<reqwest::blocking::RequestBuilder> {
80        let url = format!("{}{}", self.baseurl, path);
81        Ok(self.client.put(url))
82    }
83
84    pub(crate) fn delete(&self, path: &str) -> Result<reqwest::blocking::Response> {
85        let url = format!("{}{}", self.baseurl, path);
86        self.client.delete(url).send().context("sending delete request")
87    }
88
89    pub(crate) fn delete_builder(&self, path: &str) -> Result<RequestBuilder> {
90        let url = format!("{}{}", self.baseurl, path);
91        Ok(self.client.delete(url))
92    }
93
94    /// Send a request, retrying up to 5 times on HTTP 429 responses.
95    ///
96    /// The `build` closure is called once per attempt and must produce a fresh
97    /// `RequestBuilder` each time; this lets callers with non-cloneable bodies
98    /// (e.g. multipart forms) participate in retries.
99    pub(crate) fn send_retrying<F>(&self, mut build: F) -> Result<Response>
100    where
101        F: FnMut() -> Result<RequestBuilder>,
102    {
103        let mut attempt: u32 = 0;
104        loop {
105            let rb = build()?;
106            let response = rb.send().context("sending request")?;
107            if response.status() != StatusCode::TOO_MANY_REQUESTS {
108                return Ok(response);
109            }
110            let headers = response.headers().clone();
111            let body = response
112                .text()
113                .unwrap_or_else(|_| "<failed to read 429 body>".to_string());
114            if attempt >= MAX_RATE_LIMIT_RETRIES {
115                return Err(anyhow!(
116                    "rate-limited after {} retries: {}",
117                    MAX_RATE_LIMIT_RETRIES,
118                    summarize_rate_limit_body(&body)
119                ));
120            }
121            attempt += 1;
122            let wait = parse_rate_limit_wait(&headers, &body) + RETRY_BUFFER;
123            eprintln!(
124                "Rate limited, waiting {}s (retry {}/{})",
125                wait.as_secs(),
126                attempt,
127                MAX_RATE_LIMIT_RETRIES
128            );
129            std::thread::sleep(wait);
130        }
131    }
132
133    /// Fetch the Discourse site title.
134    pub fn fetch_site_title(&self) -> Result<String> {
135        let site_json_error = match self.get("/site.json") {
136            Ok(response) => {
137                let status = response.status();
138                let text = response.text().context("reading site.json response body")?;
139                if status.is_success() {
140                    let body: SiteResponse =
141                        serde_json::from_str(&text).context("parsing site.json")?;
142                    return Ok(body.site.title);
143                }
144                anyhow!("site.json request failed with {}", status)
145            }
146            Err(err) => err,
147        };
148
149        let response = self.get("/")?;
150        let status = response.status();
151        let html = response.text().context("reading site HTML")?;
152        if !status.is_success() {
153            return Err(anyhow!(
154                "site title lookup failed (site.json error: {}; HTML request failed with {})",
155                site_json_error,
156                status
157            ));
158        }
159        if let Some(title) = extract_html_title(&html) {
160            return Ok(title);
161        }
162        Err(anyhow!(
163            "site title lookup failed (site.json error: {}; HTML missing <title>)",
164            site_json_error
165        ))
166    }
167
168    /// Fetch the current Discourse version and commit hash.
169    pub fn fetch_version_info(&self) -> Result<VersionInfo> {
170        let mut version = None;
171        let mut commit = None;
172        let mut last_err = None;
173
174        match self.get("/about.json") {
175            Ok(response) => {
176                let status = response.status();
177                match response.json::<AboutResponse>() {
178                    Ok(body) => {
179                        if status.is_success() {
180                            version = body.about.version.or(body.about.installed_version);
181                        } else {
182                            last_err = Some(anyhow!("about.json request failed with {}", status));
183                        }
184                    }
185                    Err(err) => {
186                        last_err = Some(anyhow!("reading about.json: {}", err));
187                    }
188                }
189            }
190            Err(err) => {
191                last_err = Some(err);
192            }
193        }
194
195        match self.get("/") {
196            Ok(response) => {
197                let status = response.status();
198                let html = response.text().context("reading site HTML")?;
199                if !status.is_success() {
200                    last_err = Some(anyhow!("site HTML request failed with {}", status));
201                } else if let Some(content) = extract_meta_content(&html, "generator") {
202                    let (html_version, html_commit) = parse_generator_content(&content);
203                    if version.is_none() {
204                        version = html_version;
205                    }
206                    if commit.is_none() {
207                        commit = html_commit;
208                    }
209                }
210            }
211            Err(err) => {
212                last_err = Some(err);
213            }
214        }
215
216        if version.is_none() && commit.is_none() {
217            return Err(last_err.unwrap_or_else(|| anyhow!("version fetch failed")));
218        }
219
220        Ok(VersionInfo { version, commit })
221    }
222
223    /// Fetch the current Discourse version (best-effort).
224    pub fn fetch_version(&self) -> Result<Option<String>> {
225        Ok(self.fetch_version_info()?.version)
226    }
227}
228
229fn extract_html_title(html: &str) -> Option<String> {
230    let haystack = html.as_bytes();
231    let mut lower = Vec::with_capacity(haystack.len());
232    for &byte in haystack {
233        lower.push(byte.to_ascii_lowercase());
234    }
235    let open_tag = b"<title>";
236    let close_tag = b"</title>";
237    let start = find_subslice(&lower, open_tag)? + open_tag.len();
238    let end = find_subslice(&lower[start..], close_tag)? + start;
239    let title = String::from_utf8_lossy(&haystack[start..end])
240        .trim()
241        .to_string();
242    if title.is_empty() { None } else { Some(title) }
243}
244
245fn extract_meta_content(html: &str, name: &str) -> Option<String> {
246    let lower = html.to_ascii_lowercase();
247    let name_attr = format!("name=\"{}\"", name.to_ascii_lowercase());
248    let name_attr_single = format!("name='{}'", name.to_ascii_lowercase());
249
250    let mut start = 0;
251    while let Some(pos) = lower[start..].find("<meta") {
252        let tag_start = start + pos;
253        let rest = &lower[tag_start..];
254        let tag_end = rest.find('>')? + tag_start;
255        let tag_lower = &lower[tag_start..tag_end];
256        if tag_lower.contains(&name_attr) || tag_lower.contains(&name_attr_single) {
257            let tag_original = &html[tag_start..tag_end];
258            if let Some(value) = extract_attr_value(tag_original, "content") {
259                return Some(value);
260            }
261        }
262        start = tag_end + 1;
263    }
264    None
265}
266
267fn extract_attr_value(tag: &str, attr: &str) -> Option<String> {
268    let lower = tag.to_ascii_lowercase();
269    let attr_eq = format!("{}=", attr.to_ascii_lowercase());
270    let pos = lower.find(&attr_eq)? + attr_eq.len();
271    let rest = &tag[pos..];
272    let mut chars = rest.chars();
273    let quote = chars.next()?;
274    if quote != '"' && quote != '\'' {
275        return None;
276    }
277    let value: String = chars.take_while(|c| *c != quote).collect();
278    if value.is_empty() { None } else { Some(value) }
279}
280
281fn parse_generator_content(content: &str) -> (Option<String>, Option<String>) {
282    let mut version = None;
283    let mut commit = None;
284
285    if let Some(rest) = content.strip_prefix("Discourse ") {
286        let ver = rest.split(" - ").next().map(|s| s.trim()).unwrap_or("");
287        if !ver.is_empty() {
288            version = Some(ver.to_string());
289        }
290    }
291
292    if let Some(idx) = content.find("version ") {
293        let tail = &content[idx + "version ".len()..];
294        let hash = tail.split_whitespace().next().unwrap_or("");
295        if !hash.is_empty() {
296            commit = Some(hash.to_string());
297        }
298    }
299
300    (version, commit)
301}
302
303fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
304    if needle.is_empty() || haystack.len() < needle.len() {
305        return None;
306    }
307    haystack
308        .windows(needle.len())
309        .position(|window| window == needle)
310}