Skip to main content

dsc/api/
categories.rs

1use super::client::DiscourseClient;
2use super::error::http_error;
3use super::models::{
4    CategoriesResponse, CategoryDefinition, CategoryDefinitionsResponse, CategoryInfo,
5    CategoryResponse, CreateCategoryResponse,
6};
7use anyhow::{Context, Result, anyhow};
8use reqwest::StatusCode;
9use serde_json::Value;
10use std::collections::HashMap;
11
12impl DiscourseClient {
13    /// Fetch a category by ID (topics list included).
14    pub fn fetch_category(&self, category_id: u64) -> Result<CategoryResponse> {
15        let path = format!("/c/{}.json", category_id);
16        let response = self.get(&path)?;
17        let status = response.status();
18        let text = response.text().context("reading category response body")?;
19        if !status.is_success() {
20            if status == StatusCode::NOT_FOUND {
21                return Err(anyhow!("category not found: {}", category_id));
22            }
23            return Err(http_error("category request", status, &text));
24        }
25        let body: CategoryResponse =
26            serde_json::from_str(&text).context("reading category json")?;
27        Ok(body)
28    }
29
30    /// Fetch all categories.
31    pub fn fetch_categories(&self) -> Result<Vec<CategoryInfo>> {
32        let response = self.get("/categories.json?include_subcategories=true")?;
33        let status = response.status();
34        let text = response
35            .text()
36            .context("reading categories response body")?;
37        if !status.is_success() {
38            return Err(http_error("categories request", status, &text));
39        }
40        let body: CategoriesResponse =
41            serde_json::from_str(&text).context("reading categories json")?;
42        let mut categories = body.category_list.categories;
43        if let Ok(site_categories) = self.fetch_site_categories() {
44            let mut seen = HashMap::new();
45            for (idx, cat) in categories.iter().enumerate() {
46                if let Some(id) = cat.id {
47                    seen.insert(id, idx);
48                }
49            }
50            for cat in site_categories {
51                if let Some(id) = cat.id
52                    && !seen.contains_key(&id)
53                {
54                    categories.push(cat);
55                }
56            }
57        }
58        Ok(categories)
59    }
60
61    /// Create a category with basic fields copied from a source category.
62    pub fn create_category(&self, category: &CategoryInfo) -> Result<u64> {
63        let mut payload = vec![("name", category.name.clone())];
64        if !category.slug.is_empty() {
65            payload.push(("slug", category.slug.clone()));
66        }
67        if let Some(color) = category.color.clone() {
68            payload.push(("color", color));
69        }
70        if let Some(text_color) = category.text_color.clone() {
71            payload.push(("text_color", text_color));
72        }
73        let response = self.send_retrying(|| Ok(self.post("/categories")?.form(&payload)))?;
74        let status = response.status();
75        let text = response.text().context("reading category response body")?;
76        if !status.is_success() {
77            return Err(http_error("create category request", status, &text));
78        }
79        let body: CreateCategoryResponse =
80            serde_json::from_str(&text).context("reading category response")?;
81        Ok(body.category.id)
82    }
83
84    /// Fetch the full definition of every category, including group permissions.
85    /// This is the read side of `category def pull` / `show` / `get`; unlike
86    /// `fetch_categories` it carries description, permissions, topic template,
87    /// tag rules, and ordering.
88    pub fn fetch_category_definitions(&self) -> Result<Vec<CategoryDefinition>> {
89        let response =
90            self.get("/categories.json?show_permissions=true&include_subcategories=true")?;
91        let status = response.status();
92        let text = response
93            .text()
94            .context("reading category definitions response body")?;
95        if !status.is_success() {
96            return Err(http_error("category definitions request", status, &text));
97        }
98        let body: CategoryDefinitionsResponse =
99            serde_json::from_str(&text).context("reading category definitions json")?;
100        Ok(body.category_list.categories)
101    }
102
103    /// Create a category from raw form params, returning the new category's ID.
104    /// The caller assembles the definition params (name is required); this is
105    /// the full-definition counterpart to [`create_category`], which sends only
106    /// the four fields `category copy` needs.
107    pub fn create_category_def(&self, params: &[(String, String)]) -> Result<u64> {
108        let response = self.send_retrying(|| Ok(self.post("/categories")?.form(params)))?;
109        let status = response.status();
110        let text = response
111            .text()
112            .context("reading create category response body")?;
113        if !status.is_success() {
114            return Err(http_error("create category request", status, &text));
115        }
116        let body: CreateCategoryResponse =
117            serde_json::from_str(&text).context("reading create category response")?;
118        Ok(body.category.id)
119    }
120
121    /// Update a category's definition from raw form params
122    /// (`PUT /categories/{id}.json`). The endpoint missing from `dsc` until now.
123    pub fn update_category(&self, id: u64, params: &[(String, String)]) -> Result<()> {
124        let path = format!("/categories/{}.json", id);
125        let response = self.send_retrying(|| Ok(self.put(&path)?.form(params)))?;
126        let status = response.status();
127        let text = response
128            .text()
129            .context("reading update category response body")?;
130        if !status.is_success() {
131            return Err(http_error("update category request", status, &text));
132        }
133        Ok(())
134    }
135
136    fn fetch_site_categories(&self) -> Result<Vec<CategoryInfo>> {
137        let response = self.get("/site.json")?;
138        let status = response.status();
139        let text = response.text().context("reading site.json response body")?;
140        if !status.is_success() {
141            return Err(http_error("site.json request", status, &text));
142        }
143        let value: Value = serde_json::from_str(&text).context("parsing site.json")?;
144        let array = value
145            .get("categories")
146            .and_then(|v| v.as_array())
147            .or_else(|| {
148                value
149                    .get("site")
150                    .and_then(|v| v.get("categories"))
151                    .and_then(|v| v.as_array())
152            })
153            .ok_or_else(|| anyhow!("site.json missing categories list"))?;
154        let mut categories = Vec::new();
155        for item in array {
156            if let Ok(cat) = serde_json::from_value::<CategoryInfo>(item.clone()) {
157                categories.push(cat);
158            }
159        }
160        Ok(categories)
161    }
162}