use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use serde_json::json;
use std::time::Duration;
#[cfg(not(target_arch = "wasm32"))]
use std::time::Instant;
#[cfg(target_arch = "wasm32")]
use web_time::Instant;
pub mod sse;
pub use sse::SseEvent;
async fn sleep(d: Duration) {
#[cfg(not(target_arch = "wasm32"))]
{
tokio::time::sleep(d).await;
}
#[cfg(target_arch = "wasm32")]
{
gloo_timers::future::TimeoutFuture::new(d.as_millis() as u32).await;
}
}
#[derive(Debug, thiserror::Error)]
pub enum FabricError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("API error ({code}): {message}")]
Api { code: String, message: String },
#[error("{0}")]
Other(String),
}
pub type Result<T> = std::result::Result<T, FabricError>;
pub struct FabricClient {
client: reqwest::Client,
base_url: String,
organization_id: String,
team_id: Option<String>,
user_id: Option<String>,
}
impl FabricClient {
pub fn new(base_url: &str, api_key: &str) -> Result<Self> {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {api_key}"))
.map_err(|e| FabricError::Other(e.to_string()))?,
);
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let client = reqwest::Client::builder()
.default_headers(headers)
.build()?;
Ok(Self {
client,
base_url: base_url.trim_end_matches('/').to_string(),
organization_id: String::new(),
team_id: None,
user_id: None,
})
}
pub fn with_principal(base_url: &str, principal_id: &str) -> Result<Self> {
let mut headers = HeaderMap::new();
headers.insert(
"X-Principal-Id",
HeaderValue::from_str(principal_id).map_err(|e| FabricError::Other(e.to_string()))?,
);
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let client = reqwest::Client::builder()
.default_headers(headers)
.build()?;
Ok(Self {
client,
base_url: base_url.trim_end_matches('/').to_string(),
organization_id: String::new(),
team_id: None,
user_id: None,
})
}
pub fn set_organization_id(&mut self, org_id: &str) {
self.organization_id = org_id.to_string();
}
pub fn set_team_id(&mut self, team_id: Option<&str>) {
self.team_id = team_id.map(str::to_string);
}
pub fn set_user_id(&mut self, user_id: Option<&str>) {
self.user_id = user_id.map(str::to_string);
}
fn resolve_org_id(&self, org_id: Option<&str>) -> Result<String> {
org_id
.map(str::to_string)
.or_else(|| {
if self.organization_id.is_empty() {
None
} else {
Some(self.organization_id.clone())
}
})
.ok_or_else(|| {
FabricError::Other(
"Organization ID required — pass it or set it on the client".to_string(),
)
})
}
async fn request<T: serde::de::DeserializeOwned>(
&self,
method: reqwest::Method,
path: &str,
body: Option<serde_json::Value>,
) -> Result<T> {
let url = format!("{}{path}", self.base_url);
let mut req = self.client.request(method, &url);
if let Some(b) = body {
req = req.json(&b);
}
let resp = req.send().await?;
let status = resp.status();
let json: serde_json::Value = resp.json().await?;
if let Some(err) = json.get("error") {
return Err(FabricError::Api {
code: status.as_u16().to_string(),
message: err
.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| err.to_string()),
});
}
if let Some(data) = json.get("data") {
serde_json::from_value(data.clone())
.map_err(|e| FabricError::Other(format!("Failed to deserialize data: {e}")))
} else {
serde_json::from_value(json)
.map_err(|e| FabricError::Other(format!("Failed to deserialize response: {e}")))
}
}
async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
self.request(reqwest::Method::GET, path, None).await
}
async fn post<T: serde::de::DeserializeOwned>(
&self,
path: &str,
body: serde_json::Value,
) -> Result<T> {
self.request(reqwest::Method::POST, path, Some(body)).await
}
async fn post_empty(&self, path: &str) -> Result<()> {
let url = format!("{}{path}", self.base_url);
let resp = self.client.post(&url).send().await?;
let status = resp.status();
let json: serde_json::Value = resp.json().await?;
if let Some(err) = json.get("error") {
return Err(FabricError::Api {
code: status.as_u16().to_string(),
message: err
.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| err.to_string()),
});
}
Ok(())
}
async fn delete_req(&self, path: &str) -> Result<()> {
let url = format!("{}{path}", self.base_url);
let resp = self.client.delete(&url).send().await?;
let status = resp.status();
let json: serde_json::Value = resp.json().await?;
if let Some(err) = json.get("error") {
return Err(FabricError::Api {
code: status.as_u16().to_string(),
message: err
.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| err.to_string()),
});
}
Ok(())
}
pub async fn health_check(&self) -> Result<serde_json::Value> {
self.get("/health").await
}
pub async fn system_status(&self) -> Result<serde_json::Value> {
self.get("/v1/system/status").await
}
pub async fn get_me(&self) -> Result<serde_json::Value> {
self.get("/v1/me").await
}
pub async fn get_my_organizations(&self) -> Result<Vec<serde_json::Value>> {
self.get("/v1/me/organizations").await
}
pub async fn get_my_teams(&self) -> Result<Vec<serde_json::Value>> {
self.get("/v1/me/teams").await
}
pub async fn get_my_permissions(&self) -> Result<Vec<serde_json::Value>> {
self.get("/v1/me/permissions").await
}
pub async fn create_organization(&self, slug: &str, name: &str) -> Result<serde_json::Value> {
self.post("/v1/organizations", json!({ "slug": slug, "name": name }))
.await
}
pub async fn list_organizations(&self) -> Result<Vec<serde_json::Value>> {
self.get("/v1/organizations").await
}
pub async fn get_organization(&self, org_id: Option<&str>) -> Result<serde_json::Value> {
let id = self.resolve_org_id(org_id)?;
self.get(&format!("/v1/organizations/{id}")).await
}
pub async fn list_org_teams(&self, org_id: Option<&str>) -> Result<Vec<serde_json::Value>> {
let id = self.resolve_org_id(org_id)?;
self.get(&format!("/v1/organizations/{id}/teams")).await
}
pub async fn list_org_members(&self, org_id: Option<&str>) -> Result<Vec<serde_json::Value>> {
let id = self.resolve_org_id(org_id)?;
self.get(&format!("/v1/organizations/{id}/members")).await
}
pub async fn create_team(
&self,
org_id: Option<&str>,
slug: &str,
name: &str,
) -> Result<serde_json::Value> {
let id = self.resolve_org_id(org_id)?;
self.post(
&format!("/v1/organizations/{id}/teams"),
json!({ "slug": slug, "name": name }),
)
.await
}
pub async fn get_team(&self, team_id: &str) -> Result<serde_json::Value> {
self.get(&format!("/v1/teams/{team_id}")).await
}
pub async fn create_invitation(
&self,
org_id: Option<&str>,
email: &str,
role: &str,
) -> Result<serde_json::Value> {
let id = self.resolve_org_id(org_id)?;
self.post(
&format!("/v1/organizations/{id}/invitations"),
json!({ "email": email, "role": role }),
)
.await
}
pub async fn accept_invitation(&self, invitation_id: &str) -> Result<()> {
self.post_empty(&format!("/v1/invitations/{invitation_id}/accept"))
.await
}
pub async fn revoke_invitation(&self, invitation_id: &str) -> Result<()> {
self.post_empty(&format!("/v1/invitations/{invitation_id}/revoke"))
.await
}
pub async fn check_permission(&self, action: &str, resource: Option<&str>) -> Result<bool> {
let mut body = json!({ "action": action });
if let Some(r) = resource {
body["resource"] = serde_json::Value::String(r.to_string());
}
let resp: serde_json::Value = self.post("/v1/authz/check", body).await?;
Ok(resp
.get("allowed")
.and_then(|v| v.as_bool())
.unwrap_or(false))
}
pub async fn check_permissions(
&self,
checks: Vec<serde_json::Value>,
) -> Result<Vec<serde_json::Value>> {
self.post("/v1/authz/check-batch", json!({ "checks": checks }))
.await
}
pub async fn create_api_key(
&self,
name: &str,
org_id: Option<&str>,
scopes: Option<Vec<&str>>,
) -> Result<serde_json::Value> {
let id = self.resolve_org_id(org_id)?;
let mut body = json!({ "name": name, "organization_id": id });
if let Some(s) = scopes {
body["scopes"] = serde_json::Value::from(s);
}
self.post("/v1/api-keys", body).await
}
pub async fn list_api_keys(&self) -> Result<Vec<serde_json::Value>> {
self.get("/v1/api-keys").await
}
pub async fn get_api_key(&self, key_id: &str) -> Result<serde_json::Value> {
self.get(&format!("/v1/api-keys/{key_id}")).await
}
pub async fn delete_api_key(&self, key_id: &str) -> Result<()> {
self.delete_req(&format!("/v1/api-keys/{key_id}")).await
}
pub async fn disable_api_key(&self, key_id: &str) -> Result<()> {
self.post_empty(&format!("/v1/api-keys/{key_id}/disable"))
.await
}
pub async fn rotate_api_key(&self, key_id: &str) -> Result<serde_json::Value> {
self.post(&format!("/v1/api-keys/{key_id}/rotate"), json!({}))
.await
}
pub async fn upsert_workflow(&self, name: &str, body: serde_json::Value) -> Result<String> {
let mut payload = body;
payload["name"] = serde_json::Value::String(name.to_string());
let resp: serde_json::Value = self.post("/v1/workflow-registry", payload).await?;
resp.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| FabricError::Other("Missing workflow id in response".to_string()))
}
pub async fn list_workflows(&self) -> Result<Vec<serde_json::Value>> {
let mut params = vec![("limit", "500".to_string())];
if !self.organization_id.is_empty() {
params.push(("organization_id", self.organization_id.clone()));
if let Some(team) = &self.team_id {
params.push(("team_id", team.clone()));
}
}
let qs = params
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("&");
self.get(&format!("/v1/workflow-registry?{qs}")).await
}
pub async fn run_workflow(
&self,
workflow_name: &str,
input: serde_json::Value,
) -> Result<String> {
self.run_workflow_opts(workflow_name, input, false).await
}
pub async fn run_workflow_validated(
&self,
workflow_name: &str,
input: serde_json::Value,
) -> Result<String> {
self.run_workflow_opts(workflow_name, input, true).await
}
async fn run_workflow_opts(
&self,
workflow_name: &str,
input: serde_json::Value,
validate: bool,
) -> Result<String> {
let mut path = format!("/v1/workflows/run?name={}", urlencode(workflow_name));
if !self.organization_id.is_empty() {
path.push_str(&format!("&organization_id={}", self.organization_id));
}
if let Some(team) = &self.team_id {
path.push_str(&format!("&team_id={team}"));
}
if validate {
path.push_str("&validate=true");
}
let resp: serde_json::Value = self.post(&path, json!({ "input": input })).await?;
resp.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| FabricError::Other("Missing run id in response".to_string()))
}
pub async fn get_run(&self, run_id: &str) -> Result<serde_json::Value> {
self.get(&format!("/v1/workflows/runs/{run_id}")).await
}
pub async fn get_run_output(
&self,
run_id: &str,
expires_in_secs: Option<u32>,
) -> Result<serde_json::Value> {
let path = match expires_in_secs {
Some(ttl) => format!("/v1/workflows/runs/{run_id}/output?expires_in={ttl}"),
None => format!("/v1/workflows/runs/{run_id}/output"),
};
self.get(&path).await
}
pub async fn get_workflow_schemas(&self, name: &str) -> Result<serde_json::Value> {
let mut path = format!("/v1/workflow-schemas/{}", urlencode(name));
if !self.organization_id.is_empty() {
path.push_str(&format!("?organization_id={}", self.organization_id));
}
self.get(&path).await
}
pub async fn cancel_run(&self, run_id: &str) -> Result<()> {
self.post_empty(&format!("/v1/workflows/runs/{run_id}/cancel"))
.await
}
pub async fn pause_run(&self, run_id: &str) -> Result<()> {
self.post_empty(&format!("/v1/workflows/runs/{run_id}/pause"))
.await
}
pub async fn resume_run(&self, run_id: &str) -> Result<()> {
self.post_empty(&format!("/v1/workflows/runs/{run_id}/resume"))
.await
}
pub async fn wait_for_run(&self, run_id: &str) -> Result<serde_json::Value> {
let timeout = Duration::from_secs(300);
let poll_interval = Duration::from_secs(2);
let start = Instant::now();
loop {
let run = self.get_run(run_id).await?;
if let Some("completed" | "failed" | "cancelled") =
run.get("status").and_then(|v| v.as_str())
{
return Ok(run);
}
if start.elapsed() >= timeout {
return Err(FabricError::Other(format!(
"Timed out waiting for run {run_id} after {timeout:?}"
)));
}
sleep(poll_interval).await;
}
}
pub async fn list_runs(
&self,
organization_id: Option<&str>,
team_id: Option<&str>,
created_by: Option<&str>,
limit: Option<i64>,
offset: Option<i64>,
) -> Result<Vec<serde_json::Value>> {
let org = self.resolve_org_id(organization_id)?;
let team = team_id.map(str::to_string).or_else(|| self.team_id.clone());
let user = created_by
.map(str::to_string)
.or_else(|| self.user_id.clone());
let mut path = format!("/v1/workflows/runs?organization_id={}", urlencode(&org));
if let Some(t) = team.as_deref() {
path.push_str(&format!("&team_id={}", urlencode(t)));
}
if let Some(u) = user.as_deref() {
path.push_str(&format!("&created_by={}", urlencode(u)));
}
if let Some(l) = limit {
path.push_str(&format!("&limit={l}"));
}
if let Some(o) = offset {
path.push_str(&format!("&offset={o}"));
}
self.get(&path).await
}
pub async fn list_waiting_runs(
&self,
organization_id: Option<&str>,
team_id: Option<&str>,
created_by: Option<&str>,
) -> Result<Vec<serde_json::Value>> {
let org = self.resolve_org_id(organization_id)?;
let team = team_id.map(str::to_string).or_else(|| self.team_id.clone());
let user = created_by
.map(str::to_string)
.or_else(|| self.user_id.clone());
let mut path = format!(
"/v1/workflows/runs/waiting?organization_id={}",
urlencode(&org)
);
if let Some(t) = team.as_deref() {
path.push_str(&format!("&team_id={}", urlencode(t)));
}
if let Some(u) = user.as_deref() {
path.push_str(&format!("&created_by={}", urlencode(u)));
}
self.get(&path).await
}
pub async fn cancel_run_with_reason(&self, run_id: &str, reason: Option<&str>) -> Result<()> {
let body = match reason {
Some(r) => json!({ "reason": r }),
None => json!({}),
};
let _: serde_json::Value = self
.post(&format!("/v1/workflows/runs/{run_id}/cancel"), body)
.await?;
Ok(())
}
pub async fn face_swap(
&self,
source_url: &str,
target_url: Option<&str>,
persona_gallery_id: Option<&str>,
) -> Result<serde_json::Value> {
let mut input = json!({ "source_url": source_url });
if let Some(url) = target_url {
input["target_url"] = serde_json::Value::String(url.to_string());
}
if let Some(gid) = persona_gallery_id {
input["persona_gallery_id"] = serde_json::Value::String(gid.to_string());
}
let run_id = self.run_workflow("video/face-swap", input).await?;
self.wait_for_run(&run_id).await
}
pub async fn motion_transfer(
&self,
driving_video_url: &str,
source_image_url: Option<&str>,
persona_gallery_id: Option<&str>,
motion_model: Option<&str>,
) -> Result<serde_json::Value> {
let mut input = json!({ "driving_video_url": driving_video_url });
if let Some(url) = source_image_url {
input["source_image_url"] = serde_json::Value::String(url.to_string());
}
if let Some(gid) = persona_gallery_id {
input["persona_gallery_id"] = serde_json::Value::String(gid.to_string());
}
if let Some(model) = motion_model {
input["motion_model"] = serde_json::Value::String(model.to_string());
}
let run_id = self.run_workflow("video/motion-transfer", input).await?;
self.wait_for_run(&run_id).await
}
pub async fn list_providers(&self) -> Result<Vec<serde_json::Value>> {
self.get("/v1/providers").await
}
pub async fn execute_provider(&self, body: serde_json::Value) -> Result<serde_json::Value> {
self.post("/v1/providers/execute", body).await
}
pub async fn estimate_cost(&self, body: serde_json::Value) -> Result<serde_json::Value> {
self.post("/v1/providers/estimate", body).await
}
pub async fn get_org_usage(&self, org_id: Option<&str>) -> Result<serde_json::Value> {
let id = self.resolve_org_id(org_id)?;
self.get(&format!("/v1/organizations/{id}/usage")).await
}
pub async fn get_org_usage_daily(
&self,
org_id: Option<&str>,
) -> Result<Vec<serde_json::Value>> {
let id = self.resolve_org_id(org_id)?;
self.get(&format!("/v1/organizations/{id}/usage/daily"))
.await
}
pub async fn get_org_audit_logs(&self, org_id: Option<&str>) -> Result<Vec<serde_json::Value>> {
let id = self.resolve_org_id(org_id)?;
self.get(&format!("/v1/organizations/{id}/audit-logs"))
.await
}
pub async fn get_audit_logs(&self) -> Result<Vec<serde_json::Value>> {
self.get("/v1/audit-logs").await
}
pub async fn create_webhook(
&self,
org_id: Option<&str>,
url: &str,
events: Vec<&str>,
) -> Result<serde_json::Value> {
let id = self.resolve_org_id(org_id)?;
let body = json!({
"url": url,
"event_filter": events,
});
self.post(&format!("/v1/organizations/{id}/webhooks"), body)
.await
}
pub async fn list_webhooks(&self, org_id: Option<&str>) -> Result<Vec<serde_json::Value>> {
let id = self.resolve_org_id(org_id)?;
self.get(&format!("/v1/organizations/{id}/webhooks")).await
}
pub async fn get_webhook(&self, webhook_id: &str) -> Result<serde_json::Value> {
self.get(&format!("/v1/webhooks/{webhook_id}")).await
}
pub async fn delete_webhook(&self, webhook_id: &str) -> Result<()> {
self.delete_req(&format!("/v1/webhooks/{webhook_id}")).await
}
pub async fn set_secret(&self, org_id: Option<&str>, name: &str, value: &str) -> Result<()> {
let id = self.resolve_org_id(org_id)?;
let _: serde_json::Value = self
.post(
&format!("/v1/organizations/{id}/secrets"),
json!({ "name": name, "value": value }),
)
.await?;
Ok(())
}
pub async fn list_secrets(&self, org_id: Option<&str>) -> Result<Vec<String>> {
let id = self.resolve_org_id(org_id)?;
self.get(&format!("/v1/organizations/{id}/secrets")).await
}
pub async fn delete_secret(&self, org_id: Option<&str>, name: &str) -> Result<()> {
let id = self.resolve_org_id(org_id)?;
self.delete_req(&format!("/v1/organizations/{id}/secrets/{name}"))
.await
}
pub async fn create_schedule(
&self,
workflow_definition_id: &str,
cron: &str,
input_context: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let mut body = json!({ "cron_expression": cron });
if let Some(ctx) = input_context {
body["input_context"] = ctx;
}
self.post(
&format!("/v1/workflow-definitions/{workflow_definition_id}/schedules"),
body,
)
.await
}
pub async fn list_schedules(
&self,
workflow_definition_id: &str,
) -> Result<Vec<serde_json::Value>> {
self.get(&format!(
"/v1/workflow-definitions/{workflow_definition_id}/schedules"
))
.await
}
pub async fn delete_schedule(&self, schedule_id: &str) -> Result<()> {
self.delete_req(&format!("/v1/schedules/{schedule_id}"))
.await
}
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
out.push('%');
out.push_str(&format!("{b:02X}"));
}
}
}
out
}