Menu
Public repository / master
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}")),
}
}