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(¤t_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}