infigraph_confluence/
client.rs1use 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}