1use 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#[derive(Debug, Deserialize)]
22pub struct TicketSyncData {
23 pub ticket: TicketSyncPayload,
24}
25
26#[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#[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
138pub 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}