AkurAI Build
Menu

Public repository / d63e826eb482cb963477774b04fe8a84f23f0e45

AkurAI-Drive / client/src/api.rs

Latest change 54e252bb8ef81aab2ef9033bff0874b8afe40922 - refactor(drive): share versioned API contracts by AkurAI Agent

//! 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, Instant};

pub use akurai_drive_contract::{Bucket, Identity, Presign, RemoteFile};
use akurai_drive_contract::{BucketsResponse, FilesResponse};

type BucketsResp = BucketsResponse;
type FilesResp = FilesResponse;

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)
    }

    /// `POST /auth/password` `{email, password}` — native sign-in that returns a
    /// device token without opening a browser. The backend runs the IDP login
    /// flow server-side; we just need a token back.
    ///
    /// This does not use `self.token` (there is none yet at login time): build the
    /// client with an empty token, call this, then store the returned token.
    /// Errors are mapped to user-facing messages: 401 → bad credentials,
    /// 409 → the account has MFA and must use the browser flow.
    pub fn login_password(&self, email: &str, password: &str) -> Result<String> {
        let started = Instant::now();
        let resp = self
            .http
            .post(self.url("/auth/password"))
            .json(&serde_json::json!({ "email": email, "password": password }))
            .send()
            .context("POST /auth/password")?;
        let status = resp.status();
        log_call("POST", "/auth/password", status, started);

        if status == reqwest::StatusCode::UNAUTHORIZED {
            bail!("invalid email or password");
        }
        if status == reqwest::StatusCode::CONFLICT {
            bail!("MFA required, use browser");
        }
        let resp = check(resp, "POST /auth/password")?;

        #[derive(Deserialize)]
        struct LoginResp {
            token: String,
        }
        let body: LoginResp = resp.json().context("decoding /auth/password response")?;
        if body.token.is_empty() {
            bail!("sign-in succeeded but no token was returned");
        }
        Ok(body.token)
    }

    /// `GET /api/me` — the caller's identity behind the device token.
    pub fn me(&self) -> Result<Identity> {
        let started = Instant::now();
        let resp = self
            .http
            .get(self.url("/api/me"))
            .bearer_auth(&self.token)
            .send()
            .context("GET /api/me")?;
        log_call("GET", "/api/me", resp.status(), started);
        let resp = check(resp, "GET /api/me")?;
        resp.json().context("decoding /api/me")
    }

    /// `GET /api/buckets`
    pub fn buckets(&self) -> Result<Vec<Bucket>> {
        let started = Instant::now();
        let resp = self
            .http
            .get(self.url("/api/buckets"))
            .bearer_auth(&self.token)
            .send()
            .context("GET /api/buckets")?;
        log_call("GET", "/api/buckets", resp.status(), started);
        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 started = Instant::now();
        let resp = self
            .http
            .get(self.url("/api/files"))
            .query(&[("bucket", bucket), ("prefix", prefix)])
            .bearer_auth(&self.token)
            .send()
            .context("GET /api/files")?;
        log_call("GET", "/api/files", resp.status(), started);
        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 started = Instant::now();
        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")?;
        log_call("POST", "/api/files/presign", resp.status(), started);
        tracing::debug!(%key, %op, "presigned url issued");
        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 started = Instant::now();
        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")?;
        log_call("DELETE", "/api/files", resp.status(), started);
        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 started = Instant::now();
        let resp = rb.send().context("uploading to presigned URL")?;
        tracing::debug!(op = "S3 PUT", status = resp.status().as_u16(), ms = started.elapsed().as_millis() as u64, path = %path.display(), "s3 transfer");
        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 started = Instant::now();
        let resp = self
            .http
            .request(method, &presign.url)
            .send()
            .context("downloading from presigned URL")?;
        tracing::debug!(op = "S3 GET", status = resp.status().as_u16(), ms = started.elapsed().as_millis() as u64, dest = %dest.display(), "s3 transfer");
        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(())
    }
}

/// Emit a structured one-line record of a backend call (no-op unless a `tracing`
/// subscriber is installed — the CLI installs none, the desktop app does).
fn log_call(method: &str, path: &str, status: reqwest::StatusCode, started: Instant) {
    tracing::debug!(
        %method,
        %path,
        status = status.as_u16(),
        ms = started.elapsed().as_millis() as u64,
        "backend call"
    );
}

/// 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}")),
    }
}