Skip to main content

dsc/api/
client.rs

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