Skip to main content

dsc/api/
themes.rs

1use anyhow::{Context, Result, anyhow};
2use serde_json::{Value, json};
3use std::path::Path;
4
5use super::client::DiscourseClient;
6use super::error::http_error;
7
8impl DiscourseClient {
9    /// List installed themes on the Discourse instance.
10    pub fn list_themes(&self) -> Result<Value> {
11        let response = self.get("/admin/themes.json")?;
12        let status = response.status();
13        let text = response.text().context("reading themes response body")?;
14        if !status.is_success() {
15            return Err(http_error("themes request", status, &text));
16        }
17        let value: Value = serde_json::from_str(&text).context("parsing themes response")?;
18        Ok(value)
19    }
20
21    /// Fetch a single theme by ID.
22    pub fn fetch_theme(&self, theme_id: u64) -> Result<Value> {
23        let response = self.get(&format!("/admin/themes/{}.json", theme_id))?;
24        let status = response.status();
25        let text = response.text().context("reading theme response body")?;
26        if !status.is_success() {
27            return Err(http_error("theme request", status, &text));
28        }
29        let value: Value = serde_json::from_str(&text).context("parsing theme response")?;
30        Ok(value)
31    }
32
33    /// Create a new theme and return its ID.
34    pub fn create_theme(&self, theme: &Value) -> Result<u64> {
35        let payload = json!({ "theme": theme });
36        let response =
37            self.send_retrying(|| Ok(self.post("/admin/themes.json")?.json(&payload)))?;
38        let status = response.status();
39        let text = response.text().context("reading create theme response")?;
40        if !status.is_success() {
41            return Err(http_error("create theme request", status, &text));
42        }
43        let value: Value = serde_json::from_str(&text).context("parsing create theme response")?;
44        let id = value
45            .get("theme")
46            .and_then(|v| v.get("id"))
47            .or_else(|| value.get("id"))
48            .and_then(|v| v.as_u64())
49            .ok_or_else(|| anyhow!("missing theme id in create response"))?;
50        Ok(id)
51    }
52
53    /// Delete a theme by ID.
54    pub fn delete_theme(&self, theme_id: u64) -> Result<()> {
55        let response = self.delete(&format!("/admin/themes/{}.json", theme_id))?;
56        let status = response.status();
57        let text = response.text().context("reading delete theme response")?;
58        if !status.is_success() {
59            return Err(http_error("delete theme request", status, &text));
60        }
61        Ok(())
62    }
63
64    /// Update an existing theme.
65    pub fn update_theme(&self, theme_id: u64, theme: &Value) -> Result<()> {
66        let payload = json!({ "theme": theme });
67        let path = format!("/admin/themes/{}.json", theme_id);
68        let response = self.send_retrying(|| Ok(self.put(&path)?.json(&payload)))?;
69        let status = response.status();
70        let text = response.text().context("reading update theme response")?;
71        if !status.is_success() {
72            return Err(http_error("update theme request", status, &text));
73        }
74        Ok(())
75    }
76
77    /// Set a single theme/component setting via
78    /// `PUT /admin/themes/:id/setting.json` with `name` + `value` form fields.
79    /// For JSON-schema list settings, `value` is the JSON text as a string
80    /// (the caller passes it through verbatim).
81    pub fn set_theme_setting(&self, theme_id: u64, name: &str, value: &str) -> Result<()> {
82        let path = format!("/admin/themes/{}/setting.json", theme_id);
83        let payload = [("name", name), ("value", value)];
84        let response = self.send_retrying(|| Ok(self.put(&path)?.form(&payload)))?;
85        let status = response.status();
86        let text = response.text().context("reading theme setting response")?;
87        if !status.is_success() {
88            return Err(http_error("theme setting update request", status, &text));
89        }
90        Ok(())
91    }
92
93    /// Flip a boolean flag on a theme via `PUT /admin/themes/:id.json` and
94    /// return the updated theme JSON. Used for the remote-theme lifecycle:
95    /// `remote_check` refreshes `commits_behind` without pulling, and
96    /// `remote_update` pulls the latest upstream commit.
97    pub fn put_theme_flag(&self, theme_id: u64, flag: &str) -> Result<Value> {
98        let payload = json!({ "theme": { flag: true } });
99        let path = format!("/admin/themes/{}.json", theme_id);
100        let response = self.send_retrying(|| Ok(self.put(&path)?.json(&payload)))?;
101        let status = response.status();
102        let text = response.text().context("reading theme flag response")?;
103        if !status.is_success() {
104            return Err(http_error("theme flag update request", status, &text));
105        }
106        let value: Value = serde_json::from_str(&text).context("parsing theme flag response")?;
107        Ok(value)
108    }
109
110    /// Import a theme/component from a git repo via
111    /// `POST /admin/themes/import.json`. `remote` may embed credentials for a
112    /// private repo (`https://user:token@host/...`). Returns the created theme
113    /// JSON. Retries only on 429 (which means the import didn't run), so a slow
114    /// clone won't double-import.
115    pub fn import_theme_remote(&self, remote: &str, branch: Option<&str>) -> Result<Value> {
116        let mut form: Vec<(&str, &str)> = vec![("remote", remote)];
117        if let Some(b) = branch.filter(|b| !b.is_empty()) {
118            form.push(("branch", b));
119        }
120        let response =
121            self.send_retrying(|| Ok(self.post("/admin/themes/import.json")?.form(&form)))?;
122        let status = response.status();
123        let text = response.text().context("reading theme import response")?;
124        if !status.is_success() {
125            return Err(http_error("theme import request", status, &text));
126        }
127        serde_json::from_str(&text).context("parsing theme import response")
128    }
129
130    /// Import a theme/component from a local bundle file (`.tar.gz`/zip export)
131    /// via `POST /admin/themes/import.json` with the `bundle` multipart part.
132    pub fn import_theme_bundle(&self, file: &Path) -> Result<Value> {
133        let make_form = || -> Result<reqwest::blocking::multipart::Form> {
134            let bytes =
135                std::fs::read(file).with_context(|| format!("reading {}", file.display()))?;
136            let filename = file
137                .file_name()
138                .and_then(|s| s.to_str())
139                .ok_or_else(|| anyhow!("theme bundle path missing filename: {}", file.display()))?
140                .to_string();
141            let part = reqwest::blocking::multipart::Part::bytes(bytes).file_name(filename);
142            Ok(reqwest::blocking::multipart::Form::new().part("bundle", part))
143        };
144        let response = self.send_retrying(|| {
145            Ok(self
146                .post("/admin/themes/import.json")?
147                .multipart(make_form()?))
148        })?;
149        let status = response.status();
150        let text = response
151            .text()
152            .context("reading theme bundle import response")?;
153        if !status.is_success() {
154            return Err(http_error("theme bundle import request", status, &text));
155        }
156        serde_json::from_str(&text).context("parsing theme bundle import response")
157    }
158}