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