paperless-cli 2.1.0

Rust TUI and LLM-friendly client for Paperless-ngx
Documentation
use anyhow::{Context, Result, anyhow, bail};
use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT};
use reqwest::{Response, Url};
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;

use crate::config::Config;
use crate::models::Paginated;

#[derive(Clone, Debug)]
pub struct PaperlessClient {
    http: reqwest::Client,
    base_url: Url,
    config: Config,
}

impl PaperlessClient {
    pub fn new(config: Config) -> Result<Self> {
        let base_url = Url::parse(&format!("{}/", config.url))
            .with_context(|| format!("Invalid Paperless URL: {}", config.url))?;

        let mut headers = HeaderMap::new();
        headers.insert(
            AUTHORIZATION,
            HeaderValue::from_str(&format!("Token {}", config.token))
                .context("Unable to build authorization header")?,
        );
        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
        headers.insert(USER_AGENT, HeaderValue::from_static("paperless-cli-rs/0.1.0"));

        let http = reqwest::Client::builder()
            .default_headers(headers)
            .timeout(std::time::Duration::from_secs(30))
            .build()?;

        Ok(Self {
            http,
            base_url,
            config,
        })
    }

    pub fn config(&self) -> &Config {
        &self.config
    }

    pub async fn get<T>(&self, path: &str, params: &[(String, String)]) -> Result<T>
    where
        T: DeserializeOwned,
    {
        let response = self
            .http
            .get(self.api_url(path)?)
            .query(params)
            .send()
            .await?;
        decode_json(response).await
    }

    pub async fn get_raw(&self, path: &str, params: &[(String, String)]) -> Result<Response> {
        let response = self
            .http
            .get(self.api_url(path)?)
            .query(params)
            .send()
            .await?;
        check_response(response).await
    }

    pub async fn post_json<T, B>(&self, path: &str, body: &B) -> Result<T>
    where
        T: DeserializeOwned,
        B: Serialize + ?Sized,
    {
        let response = self
            .http
            .post(self.api_url(path)?)
            .json(body)
            .send()
            .await?;
        decode_json(response).await
    }

    pub async fn post_optional_json<T, B>(&self, path: &str, body: &B) -> Result<T>
    where
        T: DeserializeOwned + Default,
        B: Serialize + ?Sized,
    {
        let response = self
            .http
            .post(self.api_url(path)?)
            .json(body)
            .send()
            .await?;
        decode_json_allow_empty(response).await
    }

    pub async fn post_form_multipart<T>(
        &self,
        path: &str,
        form: reqwest::multipart::Form,
    ) -> Result<T>
    where
        T: DeserializeOwned + Default,
    {
        let response = self
            .http
            .post(self.api_url(path)?)
            .multipart(form)
            .send()
            .await?;
        decode_json_allow_empty(response).await
    }

    pub async fn patch_json<T, B>(&self, path: &str, body: &B) -> Result<T>
    where
        T: DeserializeOwned,
        B: Serialize + ?Sized,
    {
        let response = self
            .http
            .patch(self.api_url(path)?)
            .json(body)
            .send()
            .await?;
        decode_json(response).await
    }

    pub async fn delete(&self, path: &str) -> Result<()> {
        let response = self.http.delete(self.api_url(path)?).send().await?;
        let _ = check_response(response).await?;
        Ok(())
    }

    pub async fn paginate<T>(&self, path: &str, params: &[(String, String)]) -> Result<Vec<T>>
    where
        T: DeserializeOwned,
    {
        let mut results = Vec::new();
        let mut url = self.api_url(path)?;
        let mut first_page = true;

        loop {
            let request = if first_page {
                first_page = false;
                self.http.get(url.clone()).query(params)
            } else {
                self.http.get(url.clone())
            };

            let response = request.send().await?;
            let value = check_response(response).await?.json::<Value>().await?;
            if let Ok(items) = serde_json::from_value::<Vec<T>>(value.clone()) {
                results.extend(items);
                break;
            }

            let page = serde_json::from_value::<Paginated<T>>(value)?;
            results.extend(page.results);
            let Some(next) = page.next else {
                break;
            };
            let parsed = Url::parse(&next)?;
            if parsed.domain() != self.base_url.domain() || parsed.scheme() != self.base_url.scheme()
            {
                bail!("Refusing to follow cross-origin pagination link: {}", parsed);
            }
            url = parsed;
        }

        Ok(results)
    }

    pub async fn ping(&self) -> Result<Value> {
        self.get("status/", &[]).await
    }

    pub async fn exchange_token(base_url: &str, username: &str, password: &str) -> Result<String> {
        let url = Url::parse(&format!("{}/api/token/", base_url.trim_end_matches('/')))
            .with_context(|| format!("Invalid Paperless URL: {}", base_url))?;
        let response = reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(10))
            .build()?
            .post(url)
            .form(&[("username", username), ("password", password)])
            .send()
            .await?;

        let value = check_response(response).await?.json::<Value>().await?;
        value
            .get("token")
            .and_then(Value::as_str)
            .map(ToString::to_string)
            .ok_or_else(|| anyhow!("Token exchange response did not contain a token"))
    }

    fn api_url(&self, path: &str) -> Result<Url> {
        self.base_url
            .join(&format!("api/{}", path.trim_start_matches('/')))
            .with_context(|| format!("Invalid API path: {}", path))
    }
}

async fn decode_json<T>(response: Response) -> Result<T>
where
    T: DeserializeOwned,
{
    Ok(check_response(response).await?.json::<T>().await?)
}

async fn decode_json_allow_empty<T>(response: Response) -> Result<T>
where
    T: DeserializeOwned + Default,
{
    let checked = check_response(response).await?;
    let bytes = checked.bytes().await?;
    if bytes.is_empty() {
        return Ok(T::default());
    }
    Ok(serde_json::from_slice::<T>(&bytes)?)
}

async fn check_response(response: Response) -> Result<Response> {
    let status = response.status();
    if status.is_success() {
        return Ok(response);
    }

    let url = response.url().to_string();
    let text = response.text().await.unwrap_or_default();
    let detail = serde_json::from_str::<Value>(&text)
        .ok()
        .map(|value| value.to_string())
        .unwrap_or(text);

    match status.as_u16() {
        401 => bail!("Authentication failed. Check your API token and run `paperless login`."),
        403 => bail!("Permission denied for this operation."),
        404 => bail!("Resource not found: {}", url),
        code => bail!("API error {}: {}", code, detail),
    }
}