Skip to main content

paperless_cli/
api.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3
4use reqwest::blocking::multipart::{Form, Part};
5use serde_json::{json, Value};
6
7use crate::config::AppConfig;
8use crate::error::AppError;
9
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub enum Endpoint {
12    Relative(String),
13    Absolute(String),
14}
15
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub enum HttpMethod {
18    Get,
19    Post,
20    Patch,
21    Put,
22    Delete,
23}
24
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct MultipartFile {
27    pub field_name: String,
28    pub file_name: String,
29    pub mime: String,
30    pub bytes: Vec<u8>,
31}
32
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct RequestData {
35    pub method: HttpMethod,
36    pub endpoint: Endpoint,
37    pub query: Vec<(String, String)>,
38    pub json_body: Option<Value>,
39    pub multipart_fields: Vec<(String, String)>,
40    pub multipart_file: Option<MultipartFile>,
41}
42
43impl RequestData {
44    pub fn new(method: HttpMethod, endpoint: impl Into<String>) -> Self {
45        Self {
46            method,
47            endpoint: Endpoint::Relative(endpoint.into()),
48            query: Vec::new(),
49            json_body: None,
50            multipart_fields: Vec::new(),
51            multipart_file: None,
52        }
53    }
54
55    pub fn absolute(method: HttpMethod, endpoint: impl Into<String>) -> Self {
56        Self {
57            method,
58            endpoint: Endpoint::Absolute(endpoint.into()),
59            query: Vec::new(),
60            json_body: None,
61            multipart_fields: Vec::new(),
62            multipart_file: None,
63        }
64    }
65}
66
67#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct ResponseData {
69    pub status: u16,
70    pub url: String,
71    pub headers: BTreeMap<String, String>,
72    pub body: Vec<u8>,
73}
74
75pub trait Transport {
76    fn send(&self, request: RequestData) -> Result<ResponseData, AppError>;
77}
78
79#[derive(Clone)]
80pub struct ReqwestTransport {
81    config: AppConfig,
82    client: reqwest::blocking::Client,
83}
84
85impl ReqwestTransport {
86    pub fn new(config: AppConfig) -> Result<Self, AppError> {
87        let client = reqwest::blocking::Client::builder()
88            .timeout(std::time::Duration::from_secs(15))
89            .build()?;
90
91        Ok(Self { config, client })
92    }
93
94    pub fn config(&self) -> &AppConfig {
95        &self.config
96    }
97}
98
99impl Transport for ReqwestTransport {
100    fn send(&self, request: RequestData) -> Result<ResponseData, AppError> {
101        let url = match request.endpoint {
102            Endpoint::Relative(path) => self.config.api_url(&path),
103            Endpoint::Absolute(url) => url,
104        };
105
106        let mut builder = match request.method {
107            HttpMethod::Get => self.client.get(&url),
108            HttpMethod::Post => self.client.post(&url),
109            HttpMethod::Patch => self.client.patch(&url),
110            HttpMethod::Put => self.client.put(&url),
111            HttpMethod::Delete => self.client.delete(&url),
112        }
113        .header("Authorization", format!("Token {}", self.config.token))
114        .header("Accept", "application/json");
115
116        if !request.query.is_empty() {
117            builder = builder.query(&request.query);
118        }
119
120        if let Some(json_body) = request.json_body {
121            builder = builder.json(&json_body);
122        } else if let Some(file) = request.multipart_file {
123            let mut form = Form::new();
124            for (key, value) in request.multipart_fields {
125                form = form.text(key, value);
126            }
127
128            let part = Part::bytes(file.bytes)
129                .file_name(file.file_name)
130                .mime_str(&file.mime)
131                .map_err(|error| AppError::Message(error.to_string()))?;
132            form = form.part(file.field_name, part);
133            builder = builder.multipart(form);
134        }
135
136        let response = builder.send()?;
137        let status = response.status().as_u16();
138        let url = response.url().to_string();
139        let headers = response
140            .headers()
141            .iter()
142            .map(|(name, value)| {
143                (
144                    name.to_string().to_lowercase(),
145                    value.to_str().unwrap_or_default().to_string(),
146                )
147            })
148            .collect::<BTreeMap<_, _>>();
149        let body = response.bytes()?.to_vec();
150
151        Ok(ResponseData {
152            status,
153            url,
154            headers,
155            body,
156        })
157    }
158}
159
160pub fn fetch_token(base_url: &str, username: &str, password: &str) -> Result<String, AppError> {
161    let client = reqwest::blocking::Client::builder()
162        .timeout(std::time::Duration::from_secs(15))
163        .build()?;
164    let url = format!("{}/api/token/", base_url.trim_end_matches('/'));
165    let response = client
166        .post(&url)
167        .form(&[("username", username), ("password", password)])
168        .send()?;
169    let status = response.status().as_u16();
170    let body = response.bytes()?.to_vec();
171    let response = ResponseData {
172        status,
173        url,
174        headers: BTreeMap::new(),
175        body,
176    };
177
178    if response.status != 200 {
179        return Err(AppError::Http {
180            status: response.status,
181            url: response.url,
182            message: "Failed to obtain token. Check your username and password.".to_string(),
183        });
184    }
185
186    let json = parse_json_body(&response)?;
187    let token = json
188        .get("token")
189        .and_then(Value::as_str)
190        .ok_or_else(|| AppError::Message("Token response missing `token`".to_string()))?;
191
192    Ok(token.to_string())
193}
194
195#[derive(Clone)]
196pub struct ApiClient<T: Transport> {
197    transport: T,
198}
199
200impl<T: Transport> ApiClient<T> {
201    pub fn new(transport: T) -> Self {
202        Self { transport }
203    }
204
205    pub fn transport(&self) -> &T {
206        &self.transport
207    }
208
209    pub fn get_json(&self, path: &str, query: Vec<(String, String)>) -> Result<Value, AppError> {
210        let mut request = RequestData::new(HttpMethod::Get, path);
211        request.query = query;
212        let response = self.transport.send(request)?;
213        self.decode_json_response(response)
214    }
215
216    pub fn get_json_absolute(&self, url: String) -> Result<Value, AppError> {
217        let request = RequestData::absolute(HttpMethod::Get, url);
218        let response = self.transport.send(request)?;
219        self.decode_json_response(response)
220    }
221
222    pub fn get_raw(
223        &self,
224        path: &str,
225        query: Vec<(String, String)>,
226    ) -> Result<ResponseData, AppError> {
227        let mut request = RequestData::new(HttpMethod::Get, path);
228        request.query = query;
229        let response = self.transport.send(request)?;
230        check_response(&response)?;
231        Ok(response)
232    }
233
234    pub fn post_json(&self, path: &str, body: Value) -> Result<Value, AppError> {
235        let mut request = RequestData::new(HttpMethod::Post, path);
236        request.json_body = Some(body);
237        let response = self.transport.send(request)?;
238        self.decode_json_response(response)
239    }
240
241    pub fn post_json_raw(&self, path: &str, body: Value) -> Result<ResponseData, AppError> {
242        let mut request = RequestData::new(HttpMethod::Post, path);
243        request.json_body = Some(body);
244        let response = self.transport.send(request)?;
245        check_response(&response)?;
246        Ok(response)
247    }
248
249    pub fn post_multipart(
250        &self,
251        path: &str,
252        fields: Vec<(String, String)>,
253        file: MultipartFile,
254    ) -> Result<Value, AppError> {
255        let mut request = RequestData::new(HttpMethod::Post, path);
256        request.multipart_fields = fields;
257        request.multipart_file = Some(file);
258        let response = self.transport.send(request)?;
259        self.decode_json_response(response)
260    }
261
262    pub fn patch_json(&self, path: &str, body: Value) -> Result<Value, AppError> {
263        let mut request = RequestData::new(HttpMethod::Patch, path);
264        request.json_body = Some(body);
265        let response = self.transport.send(request)?;
266        self.decode_json_response(response)
267    }
268
269    pub fn put_json(&self, path: &str, body: Value) -> Result<Value, AppError> {
270        let mut request = RequestData::new(HttpMethod::Put, path);
271        request.json_body = Some(body);
272        let response = self.transport.send(request)?;
273        self.decode_json_response(response)
274    }
275
276    pub fn delete(&self, path: &str) -> Result<(), AppError> {
277        let request = RequestData::new(HttpMethod::Delete, path);
278        let response = self.transport.send(request)?;
279        check_response(&response)?;
280        Ok(())
281    }
282
283    pub fn paginate(
284        &self,
285        path: &str,
286        mut query: Vec<(String, String)>,
287    ) -> Result<Vec<Value>, AppError> {
288        if !query.iter().any(|(key, _)| key == "page_size") {
289            query.push(("page_size".to_string(), "100".to_string()));
290        }
291
292        let mut next = Some(Endpoint::Relative(path.to_string()));
293        let mut params = query;
294        let mut results = Vec::new();
295
296        while let Some(endpoint) = next.take() {
297            let mut request = match endpoint {
298                Endpoint::Relative(path) => RequestData::new(HttpMethod::Get, path),
299                Endpoint::Absolute(url) => RequestData::absolute(HttpMethod::Get, url),
300            };
301            request.query = params.clone();
302
303            let response = self.transport.send(request)?;
304            check_response(&response)?;
305            let current_url = response.url.clone();
306            let value = parse_json_body(&response)?;
307            if let Some(items) = value.as_array() {
308                results.extend(items.iter().cloned());
309                break;
310            }
311
312            let response_object = value.as_object().ok_or_else(|| {
313                AppError::Message("Paginated response must be an object or list".to_string())
314            })?;
315
316            if let Some(items) = response_object.get("results").and_then(Value::as_array) {
317                results.extend(items.iter().cloned());
318            }
319
320            next = response_object
321                .get("next")
322                .and_then(Value::as_str)
323                .map(|url| validate_next_page_url(&current_url, url))
324                .transpose()?
325                .map(Endpoint::Absolute);
326            params.clear();
327        }
328
329        Ok(results)
330    }
331
332    pub fn ping(&self, base_url: &str) -> Result<Value, AppError> {
333        let response = self.get_json("status/", Vec::new())?;
334        Ok(json!({
335            "status": "ok",
336            "url": base_url,
337            "response": response,
338        }))
339    }
340
341    fn decode_json_response(&self, response: ResponseData) -> Result<Value, AppError> {
342        check_response(&response)?;
343        if response.body.is_empty() || response.status == 204 {
344            return Ok(json!({}));
345        }
346        parse_json_body(&response)
347    }
348}
349
350fn validate_next_page_url(current_url: &str, next_url: &str) -> Result<String, AppError> {
351    let current = reqwest::Url::parse(current_url)
352        .map_err(|_| AppError::Message(format!("Invalid current pagination URL: {current_url}")))?;
353    let next = reqwest::Url::parse(next_url)
354        .map_err(|_| AppError::Message(format!("Invalid pagination URL: {next_url}")))?;
355
356    if current.host_str() != next.host_str() {
357        return Err(AppError::Message(format!(
358            "Refusing cross-origin pagination link: {next_url}"
359        )));
360    }
361
362    if current.scheme() == next.scheme() {
363        if current.port_or_known_default() != next.port_or_known_default() {
364            return Err(AppError::Message(format!(
365                "Refusing cross-origin pagination link: {next_url}"
366            )));
367        }
368        return Ok(next_url.to_string());
369    }
370
371    if next.port().is_some() && current.port_or_known_default() != next.port_or_known_default() {
372        return Err(AppError::Message(format!(
373            "Refusing cross-origin pagination link: {next_url}"
374        )));
375    }
376
377    let mut normalized = current;
378    normalized.set_path(next.path());
379    normalized.set_query(next.query());
380    normalized.set_fragment(next.fragment());
381    Ok(normalized.to_string())
382}
383
384pub fn parse_json_body(response: &ResponseData) -> Result<Value, AppError> {
385    if response.body.is_empty() {
386        return Ok(json!({}));
387    }
388    Ok(serde_json::from_slice(&response.body)?)
389}
390
391pub fn check_response(response: &ResponseData) -> Result<(), AppError> {
392    match response.status {
393        200..=299 => Ok(()),
394        401 => Err(AppError::Http {
395            status: response.status,
396            url: response.url.clone(),
397            message: "Authentication failed. Run `paperless login`.".to_string(),
398        }),
399        403 => Err(AppError::Http {
400            status: response.status,
401            url: response.url.clone(),
402            message: "Permission denied.".to_string(),
403        }),
404        404 => Err(AppError::Http {
405            status: response.status,
406            url: response.url.clone(),
407            message: "Resource not found.".to_string(),
408        }),
409        _ => {
410            let message = parse_error_message(&response.body);
411            Err(AppError::Http {
412                status: response.status,
413                url: response.url.clone(),
414                message,
415            })
416        }
417    }
418}
419
420fn parse_error_message(body: &[u8]) -> String {
421    serde_json::from_slice::<Value>(body)
422        .ok()
423        .map(|json| {
424            if let Some(detail) = json.get("detail").and_then(Value::as_str) {
425                return detail.to_string();
426            }
427            json.to_string()
428        })
429        .unwrap_or_else(|| String::from_utf8_lossy(body).to_string())
430}
431
432pub fn multipart_file_from_path(path: &Path) -> Result<MultipartFile, AppError> {
433    if !path.exists() {
434        return Err(AppError::FileMissing(path.to_path_buf()));
435    }
436
437    let bytes = std::fs::read(path)?;
438    let mime = mime_guess::from_path(path)
439        .first_or_octet_stream()
440        .to_string();
441
442    Ok(MultipartFile {
443        field_name: "document".to_string(),
444        file_name: path
445            .file_name()
446            .and_then(|name| name.to_str())
447            .unwrap_or("document.bin")
448            .to_string(),
449        mime,
450        bytes,
451    })
452}