Skip to main content

infigraph_confluence/
client.rs

1use anyhow::{Context, Result};
2use serde::Deserialize;
3
4pub struct ConfluenceClient {
5    base_url: String,
6    auth_header: String,
7    agent: ureq::Agent,
8}
9
10#[derive(Debug, Clone, Deserialize)]
11pub struct ConfluencePage {
12    pub id: String,
13    pub title: String,
14    pub status: String,
15    #[serde(default)]
16    pub body: Option<PageBody>,
17    #[serde(default)]
18    pub version: Option<PageVersion>,
19    #[serde(rename = "_links", default)]
20    pub links: Option<PageLinks>,
21}
22
23#[derive(Debug, Clone, Deserialize)]
24pub struct PageBody {
25    #[serde(default)]
26    pub view: Option<BodyContent>,
27    #[serde(default)]
28    pub storage: Option<BodyContent>,
29}
30
31#[derive(Debug, Clone, Deserialize)]
32pub struct BodyContent {
33    pub value: String,
34}
35
36#[derive(Debug, Clone, Deserialize)]
37pub struct PageVersion {
38    pub number: i64,
39    #[serde(rename = "when", default)]
40    pub when_str: Option<String>,
41}
42
43#[derive(Debug, Clone, Deserialize)]
44pub struct PageLinks {
45    #[serde(rename = "webui", default)]
46    pub webui: Option<String>,
47}
48
49#[derive(Debug, Deserialize)]
50struct SearchResponse {
51    results: Vec<ConfluencePage>,
52    #[serde(default)]
53    _links: Option<SearchLinks>,
54}
55
56#[derive(Debug, Deserialize)]
57struct SearchLinks {
58    #[serde(default)]
59    next: Option<String>,
60}
61
62impl ConfluenceClient {
63    pub fn new(base_url: &str, pat: &str) -> Self {
64        let base_url = base_url.trim_end_matches('/').to_string();
65        Self {
66            base_url,
67            auth_header: format!("Bearer {pat}"),
68            agent: ureq::AgentBuilder::new()
69                .timeout(std::time::Duration::from_secs(30))
70                .build(),
71        }
72    }
73
74    pub fn new_basic(base_url: &str, email: &str, api_token: &str) -> Self {
75        use std::io::Write;
76        let base_url = base_url.trim_end_matches('/').to_string();
77        let mut buf = Vec::new();
78        write!(buf, "{email}:{api_token}").unwrap();
79        let encoded = base64_encode(&buf);
80        Self {
81            base_url,
82            auth_header: format!("Basic {encoded}"),
83            agent: ureq::AgentBuilder::new()
84                .timeout(std::time::Duration::from_secs(30))
85                .build(),
86        }
87    }
88
89    pub fn get_page(&self, page_id: &str) -> Result<ConfluencePage> {
90        let url = format!(
91            "{}/wiki/rest/api/content/{}?expand=body.view,body.storage,version",
92            self.base_url, page_id
93        );
94        let resp: ConfluencePage = self
95            .agent
96            .get(&url)
97            .set("Authorization", &self.auth_header)
98            .call()
99            .context("Confluence API request failed")?
100            .into_json()
101            .context("Failed to parse Confluence page response")?;
102        Ok(resp)
103    }
104
105    pub fn get_pages_in_space(&self, space_key: &str, limit: usize) -> Result<Vec<ConfluencePage>> {
106        let mut all_pages = Vec::new();
107        let mut start = 0;
108        loop {
109            let url = format!(
110                "{}/wiki/rest/api/content?spaceKey={}&type=page&expand=body.view,version&limit={}&start={}",
111                self.base_url, space_key, limit.min(50), start
112            );
113            let resp: SearchResponse = self
114                .agent
115                .get(&url)
116                .set("Authorization", &self.auth_header)
117                .call()
118                .with_context(|| format!("fetch pages in space {space_key}"))?
119                .into_json()
120                .context("parse space pages response")?;
121
122            let count = resp.results.len();
123            all_pages.extend(resp.results);
124
125            if count == 0 || all_pages.len() >= limit || resp._links.and_then(|l| l.next).is_none()
126            {
127                break;
128            }
129            start += count;
130        }
131        Ok(all_pages)
132    }
133
134    pub fn search_cql(&self, cql: &str, limit: usize) -> Result<Vec<ConfluencePage>> {
135        let mut all_pages = Vec::new();
136        let mut start = 0;
137        loop {
138            let url = format!(
139                "{}/wiki/rest/api/content/search?cql={}&expand=body.view,version&limit={}&start={}",
140                self.base_url,
141                urlencoding_simple(cql),
142                limit.min(50),
143                start
144            );
145            let resp: SearchResponse = self
146                .agent
147                .get(&url)
148                .set("Authorization", &self.auth_header)
149                .call()
150                .with_context(|| format!("CQL search: {cql}"))?
151                .into_json()
152                .context("parse CQL search response")?;
153
154            let count = resp.results.len();
155            all_pages.extend(resp.results);
156
157            if count == 0 || all_pages.len() >= limit || resp._links.and_then(|l| l.next).is_none()
158            {
159                break;
160            }
161            start += count;
162        }
163        Ok(all_pages)
164    }
165
166    pub fn get_pages_modified_since(
167        &self,
168        space_key: &str,
169        since: &str,
170        limit: usize,
171    ) -> Result<Vec<ConfluencePage>> {
172        let cql = format!(
173            "space = \"{}\" AND type = page AND lastModified >= \"{}\"",
174            space_key, since
175        );
176        self.search_cql(&cql, limit)
177    }
178
179    pub fn get_all_page_ids_in_space(&self, space_key: &str) -> Result<Vec<String>> {
180        let mut ids = Vec::new();
181        let mut start = 0;
182        loop {
183            let url = format!(
184                "{}/wiki/rest/api/content?spaceKey={}&type=page&limit=200&start={}",
185                self.base_url, space_key, start
186            );
187            let resp: SearchResponse = self
188                .agent
189                .get(&url)
190                .set("Authorization", &self.auth_header)
191                .call()
192                .with_context(|| format!("list page IDs in space {space_key}"))?
193                .into_json()
194                .context("parse page ID listing")?;
195
196            let count = resp.results.len();
197            ids.extend(resp.results.into_iter().map(|p| p.id));
198
199            if count == 0 || resp._links.and_then(|l| l.next).is_none() {
200                break;
201            }
202            start += count;
203        }
204        Ok(ids)
205    }
206
207    pub fn base_url(&self) -> &str {
208        &self.base_url
209    }
210}
211
212fn base64_encode(input: &[u8]) -> String {
213    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
214    let mut out = String::new();
215    for chunk in input.chunks(3) {
216        let b0 = chunk[0] as u32;
217        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
218        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
219        let triple = (b0 << 16) | (b1 << 8) | b2;
220        out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
221        out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
222        if chunk.len() > 1 {
223            out.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
224        } else {
225            out.push('=');
226        }
227        if chunk.len() > 2 {
228            out.push(CHARS[(triple & 0x3F) as usize] as char);
229        } else {
230            out.push('=');
231        }
232    }
233    out
234}
235
236fn urlencoding_simple(s: &str) -> String {
237    let mut out = String::new();
238    for b in s.bytes() {
239        match b {
240            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
241                out.push(b as char);
242            }
243            _ => {
244                out.push('%');
245                out.push_str(&format!("{:02X}", b));
246            }
247        }
248    }
249    out
250}