AkurAI Build
Menu

Public repository / feb7ac86b5e60d55b429eacb7146cf0dc32c6628

AkurAI-Drive / client/src/api.rs

Latest change feb7ac86b5e60d55b429eacb7146cf0dc32c6628 - Add native Linux sync client (akurai-drive-sync) by olibuijr

//! HTTP client for the AkurAI Drive backend and the S3 presigned URLs it hands
//! out. Backend calls carry the device token as a bearer; presigned S3 requests
//! carry no auth (the signature is in the URL).

use anyhow::{anyhow, bail, Context, Result};
use serde::Deserialize;
use std::path::Path;
use std::time::Duration;

#[derive(Debug, Clone, Deserialize)]
pub struct Bucket {
    pub bucket: String,
    #[serde(default)]
    pub region: String,
    #[serde(default)]
    pub plan: String,
    #[serde(default)]
    pub org: String,
    #[serde(default)]
    pub created: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct RemoteFile {
    pub key: String,
    #[serde(default)]
    pub size: u64,
    #[serde(default)]
    pub etag: String,
    // Part of the backend contract; retained for completeness, not used in
    // change detection (we key on ETag, not timestamps).
    #[serde(default)]
    #[allow(dead_code)]
    pub last_modified: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct Presign {
    pub url: String,
    pub method: String,
    // Informational field from the backend; the client acts immediately.
    #[serde(default)]
    #[allow(dead_code)]
    pub expires_in: u64,
}

#[derive(Debug, Deserialize)]
struct BucketsResp {
    buckets: Vec<Bucket>,
}

#[derive(Debug, Deserialize)]
struct FilesResp {
    files: Vec<RemoteFile>,
}

pub struct Api {
    http: reqwest::blocking::Client,
    base: String,
    token: String,
}

impl Api {
    pub fn new(base: String, token: String) -> Result<Self> {
        let http = reqwest::blocking::Client::builder()
            .timeout(Duration::from_secs(900))
            .build()
            .context("building HTTP client")?;
        Ok(Self { http, base, token })
    }

    fn url(&self, path: &str) -> String {
        format!("{}{}", self.base, path)
    }

    /// `GET /api/buckets`
    pub fn buckets(&self) -> Result<Vec<Bucket>> {
        let resp = self
            .http
            .get(self.url("/api/buckets"))
            .bearer_auth(&self.token)
            .send()
            .context("GET /api/buckets")?;
        let resp = check(resp, "GET /api/buckets")?;
        let body: BucketsResp = resp.json().context("decoding /api/buckets")?;
        Ok(body.buckets)
    }

    /// `GET /api/files?bucket=&prefix=`
    pub fn files(&self, bucket: &str, prefix: &str) -> Result<Vec<RemoteFile>> {
        let resp = self
            .http
            .get(self.url("/api/files"))
            .query(&[("bucket", bucket), ("prefix", prefix)])
            .bearer_auth(&self.token)
            .send()
            .context("GET /api/files")?;
        let resp = check(resp, "GET /api/files")?;
        let body: FilesResp = resp.json().context("decoding /api/files")?;
        Ok(body.files)
    }

    /// `POST /api/files/presign`
    pub fn presign(
        &self,
        bucket: &str,
        key: &str,
        op: &str,
        content_type: Option<&str>,
    ) -> Result<Presign> {
        let mut body = serde_json::json!({ "bucket": bucket, "key": key, "op": op });
        if let Some(ct) = content_type {
            body["content_type"] = serde_json::Value::String(ct.to_string());
        }
        let resp = self
            .http
            .post(self.url("/api/files/presign"))
            .bearer_auth(&self.token)
            .json(&body)
            .send()
            .context("POST /api/files/presign")?;
        let resp = check(resp, "POST /api/files/presign")?;
        resp.json().context("decoding presign response")
    }

    /// `DELETE /api/files`
    pub fn delete(&self, bucket: &str, key: &str) -> Result<()> {
        let resp = self
            .http
            .delete(self.url("/api/files"))
            .bearer_auth(&self.token)
            .json(&serde_json::json!({ "bucket": bucket, "key": key }))
            .send()
            .context("DELETE /api/files")?;
        check(resp, "DELETE /api/files")?;
        Ok(())
    }

    /// PUT a local file to a presigned URL. Returns the object's ETag if S3
    /// reports one.
    pub fn upload_file(
        &self,
        presign: &Presign,
        path: &Path,
        content_type: Option<&str>,
    ) -> Result<Option<String>> {
        let method = http_method(&presign.method)?;
        let file = std::fs::File::open(path)
            .with_context(|| format!("opening {} for upload", path.display()))?;
        let mut rb = self.http.request(method, &presign.url).body(file);
        if let Some(ct) = content_type {
            rb = rb.header(reqwest::header::CONTENT_TYPE, ct);
        }
        let resp = rb.send().context("uploading to presigned URL")?;
        let resp = check(resp, "S3 PUT")?;
        let etag = resp
            .headers()
            .get(reqwest::header::ETAG)
            .and_then(|v| v.to_str().ok())
            .map(|s| s.to_string());
        Ok(etag)
    }

    /// GET a presigned URL into a destination file (caller handles atomic
    /// rename).
    pub fn download_to(&self, presign: &Presign, dest: &Path) -> Result<()> {
        let method = http_method(&presign.method)?;
        let resp = self
            .http
            .request(method, &presign.url)
            .send()
            .context("downloading from presigned URL")?;
        let mut resp = check(resp, "S3 GET")?;
        let mut file =
            std::fs::File::create(dest).with_context(|| format!("creating {}", dest.display()))?;
        resp.copy_to(&mut file)
            .context("streaming download to disk")?;
        Ok(())
    }
}

/// Turn a non-2xx response into an error that includes a snippet of the body.
fn check(resp: reqwest::blocking::Response, what: &str) -> Result<reqwest::blocking::Response> {
    let status = resp.status();
    if status.is_success() {
        return Ok(resp);
    }
    let body = resp.text().unwrap_or_default();
    let snippet: String = body.chars().take(300).collect();
    if status == reqwest::StatusCode::UNAUTHORIZED {
        bail!("{what} returned 401 Unauthorized — run `akurai-drive-sync login` again");
    }
    bail!("{what} failed: HTTP {status}: {snippet}")
}

fn http_method(m: &str) -> Result<reqwest::Method> {
    match m.to_ascii_uppercase().as_str() {
        "PUT" => Ok(reqwest::Method::PUT),
        "GET" => Ok(reqwest::Method::GET),
        "POST" => Ok(reqwest::Method::POST),
        other => Err(anyhow!("unsupported presign method: {other}")),
    }
}