memvid_cli/
api.rs

1//! API client for Memvid control plane
2//!
3//! This module provides HTTP client functionality for interacting with the Memvid
4//! control plane API, specifically for ticket synchronization and application.
5
6use std::time::Duration;
7
8use anyhow::{anyhow, bail, Context, Result};
9use reqwest::blocking::{Client, Response};
10use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
11use serde::de::DeserializeOwned;
12use serde::Deserialize;
13use uuid::Uuid;
14
15use crate::config::CliConfig;
16
17const API_KEY_HEADER: &str = "X-API-KEY";
18const JSON_CONTENT_TYPE: &str = "application/json";
19
20/// Wrapper for ticket data in sync response
21#[derive(Debug, Deserialize)]
22pub struct TicketSyncData {
23    pub ticket: TicketSyncPayload,
24}
25
26/// Payload for ticket synchronization from the control plane
27#[derive(Debug, Deserialize)]
28pub struct TicketSyncPayload {
29    pub memory_id: Uuid,
30    #[serde(alias = "seq_no")]
31    pub sequence: i64,
32    pub issuer: String,
33    pub expires_in: u64,
34    #[serde(default)]
35    pub capacity_bytes: Option<u64>,
36    pub signature: String,
37}
38
39#[derive(Debug, Deserialize)]
40struct ApiEnvelope<T> {
41    status: String,
42    request_id: String,
43    data: Option<T>,
44    error: Option<ApiErrorBody>,
45    #[allow(dead_code)]
46    signature: String,
47}
48
49#[derive(Debug, Deserialize)]
50struct ApiErrorBody {
51    code: String,
52    message: String,
53}
54
55pub struct TicketSyncResponse {
56    pub payload: TicketSyncPayload,
57    pub request_id: String,
58}
59
60pub fn fetch_ticket(config: &CliConfig, memory_id: &Uuid) -> Result<TicketSyncResponse> {
61    let api_key = require_api_key(config)?;
62    let client = http_client()?;
63    let url = format!(
64        "{}/memories/{}/tickets/sync",
65        config.api_url.trim_end_matches('/'),
66        memory_id
67    );
68    let response = client
69        .post(url)
70        .headers(auth_headers(api_key)?)
71        .header(CONTENT_TYPE, JSON_CONTENT_TYPE)
72        .body("{}")
73        .send()
74        .with_context(|| "failed to contact ticket sync endpoint")?;
75    let envelope: ApiEnvelope<TicketSyncData> = parse_envelope(response)?;
76    let data = envelope
77        .data
78        .ok_or_else(|| anyhow!("ticket sync response missing payload"))?;
79    Ok(TicketSyncResponse {
80        payload: data.ticket,
81        request_id: envelope.request_id,
82    })
83}
84
85#[derive(serde::Serialize)]
86pub struct ApplyTicketRequest<'a> {
87    pub issuer: &'a str,
88    pub seq_no: i64,
89    pub expires_in: u64,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub capacity_bytes: Option<u64>,
92    pub signature: &'a str,
93}
94
95pub fn apply_ticket(
96    config: &CliConfig,
97    memory_id: &Uuid,
98    request: &ApplyTicketRequest<'_>,
99) -> Result<String> {
100    let api_key = require_api_key(config)?;
101    let client = http_client()?;
102    let url = format!(
103        "{}/memories/{}/tickets/apply",
104        config.api_url.trim_end_matches('/'),
105        memory_id
106    );
107    let response = client
108        .post(url)
109        .headers(auth_headers(api_key)?)
110        .json(request)
111        .send()
112        .with_context(|| "failed to contact ticket apply endpoint")?;
113    let envelope: ApiEnvelope<serde_json::Value> = parse_envelope(response)?;
114    Ok(envelope.request_id)
115}
116
117#[derive(serde::Serialize)]
118pub struct RegisterFileRequest<'a> {
119    pub file_name: &'a str,
120    pub file_path: &'a str,
121    pub file_size: i64,
122    pub machine_id: &'a str,
123}
124
125/// Response from file registration
126#[derive(Debug, Deserialize)]
127pub struct RegisterFileResponse {
128    pub id: String,
129    pub memory_id: String,
130    pub file_name: String,
131    pub file_path: String,
132    pub file_size: i64,
133    pub machine_id: String,
134    pub last_synced: String,
135    pub created_at: String,
136}
137
138/// Register a file with the control plane
139pub fn register_file(
140    config: &CliConfig,
141    memory_id: &Uuid,
142    request: &RegisterFileRequest<'_>,
143) -> Result<RegisterFileResponse> {
144    let api_key = require_api_key(config)?;
145    let client = http_client()?;
146    let url = format!(
147        "{}/memories/{}/files",
148        config.api_url.trim_end_matches('/'),
149        memory_id
150    );
151    let response = client
152        .post(url)
153        .headers(auth_headers(api_key)?)
154        .json(request)
155        .send()
156        .with_context(|| "failed to contact file registration endpoint")?;
157    let envelope: ApiEnvelope<RegisterFileResponse> = parse_envelope(response)?;
158    envelope
159        .data
160        .ok_or_else(|| anyhow!("file registration response missing payload"))
161}
162
163fn http_client() -> Result<Client> {
164    Client::builder()
165        .timeout(Duration::from_secs(15))
166        .build()
167        .map_err(|err| anyhow!("failed to construct HTTP client: {err}"))
168}
169
170fn auth_headers(api_key: &str) -> Result<HeaderMap> {
171    let mut headers = HeaderMap::new();
172    let value = HeaderValue::from_str(api_key)
173        .map_err(|_| anyhow!("API key contains invalid characters"))?;
174    headers.insert(API_KEY_HEADER, value);
175    Ok(headers)
176}
177
178fn require_api_key(config: &CliConfig) -> Result<&str> {
179    config
180        .api_key
181        .as_deref()
182        .ok_or_else(|| anyhow!("MEMVID_API_KEY is not set"))
183}
184
185fn parse_envelope<T: DeserializeOwned>(response: Response) -> Result<ApiEnvelope<T>> {
186    let status = response.status();
187    let envelope = response.json::<ApiEnvelope<T>>()?;
188    if envelope.status == "ok" {
189        return Ok(envelope);
190    }
191
192    let message = envelope
193        .error
194        .map(|err| format!("{}: {}", err.code, err.message))
195        .unwrap_or_else(|| format!("request failed with status {}", status));
196    bail!(message);
197}