1use std::collections::HashMap;
6use std::time::{Duration, SystemTime};
7
8use serde::{Deserialize, Deserializer, Serialize};
9
10use crate::client::Client;
11use crate::error::ApiError;
12use crate::models::Ref;
13
14fn deserialize_object_size<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
18 use serde::de::Error;
19 let v = i64::deserialize(d)?;
20 if v < 0 {
21 return Err(D::Error::custom(format!("invalid size (got: {v})")));
22 }
23 Ok(v as u64)
24}
25
26fn deserialize_optional_object_size<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
34 deserialize_object_size(d)
35}
36
37#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "lowercase")]
40pub enum Operation {
41 Download,
43 Upload,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct ObjectSpec {
50 pub oid: String,
52 pub size: u64,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58pub struct BatchRequest {
59 pub operation: Operation,
61 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub transfers: Vec<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub r#ref: Option<Ref>,
70 pub objects: Vec<ObjectSpec>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub hash_algo: Option<String>,
75}
76
77impl BatchRequest {
78 pub fn new(operation: Operation, objects: Vec<ObjectSpec>) -> Self {
83 Self {
84 operation,
85 transfers: Vec::new(),
86 r#ref: None,
87 objects,
88 hash_algo: None,
89 }
90 }
91
92 pub fn with_transfers(mut self, transfers: impl IntoIterator<Item = String>) -> Self {
94 self.transfers = transfers.into_iter().collect();
95 self
96 }
97
98 pub fn with_ref(mut self, r: Ref) -> Self {
100 self.r#ref = Some(r);
101 self
102 }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107pub struct BatchResponse {
108 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub transfer: Option<String>,
112 pub objects: Vec<ObjectResult>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub hash_algo: Option<String>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124pub struct ObjectResult {
125 pub oid: String,
127 #[serde(default, deserialize_with = "deserialize_optional_object_size")]
136 pub size: u64,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub authenticated: Option<bool>,
141 #[serde(default, alias = "_links", skip_serializing_if = "Option::is_none")]
144 pub actions: Option<Actions>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub error: Option<ObjectError>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
155pub struct ObjectError {
156 pub code: u32,
158 pub message: String,
160}
161
162#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
167pub struct Actions {
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub download: Option<Action>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub upload: Option<Action>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub verify: Option<Action>,
179}
180
181#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
183pub struct Action {
184 pub href: String,
186 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
188 pub header: HashMap<String, String>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub expires_in: Option<i64>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub expires_at: Option<String>,
198}
199
200impl Action {
201 pub fn is_expired_within(&self, now: SystemTime, buffer: Duration) -> bool {
210 let expiration = match (self.expires_in, self.expires_at.as_deref()) {
211 (Some(secs), _) if secs != 0 => {
212 if secs < 0 {
215 SystemTime::UNIX_EPOCH
216 } else {
217 now.checked_add(Duration::from_secs(secs as u64))
218 .unwrap_or(SystemTime::UNIX_EPOCH)
219 }
220 }
221 (_, Some(s)) => match parse_rfc3339(s) {
222 Some(t) => t,
223 None => return false,
224 },
225 _ => return false,
226 };
227 expiration < now + buffer
228 }
229}
230
231fn parse_rfc3339(s: &str) -> Option<SystemTime> {
235 let bytes = s.as_bytes();
236 if bytes.len() < 20
237 || bytes[4] != b'-'
238 || bytes[7] != b'-'
239 || bytes[10] != b'T'
240 || bytes[13] != b':'
241 || bytes[16] != b':'
242 {
243 return None;
244 }
245 let year: i32 = s.get(0..4)?.parse().ok()?;
246 let month: u32 = s.get(5..7)?.parse().ok()?;
247 let day: u32 = s.get(8..10)?.parse().ok()?;
248 let hour: u32 = s.get(11..13)?.parse().ok()?;
249 let min: u32 = s.get(14..16)?.parse().ok()?;
250 let sec: u32 = s.get(17..19)?.parse().ok()?;
251
252 let mut idx = 19;
253 if bytes.get(idx) == Some(&b'.') {
254 idx += 1;
255 while bytes.get(idx).is_some_and(|b| b.is_ascii_digit()) {
256 idx += 1;
257 }
258 }
259 let tz_secs: i64 = match bytes.get(idx) {
260 Some(b'Z') | Some(b'z') => 0,
261 Some(b'+') | Some(b'-') => {
262 let sign = if bytes[idx] == b'+' { 1 } else { -1 };
263 let h: i64 = s.get(idx + 1..idx + 3)?.parse().ok()?;
264 let m: i64 = s.get(idx + 4..idx + 6)?.parse().ok()?;
265 sign * (h * 3600 + m * 60)
266 }
267 _ => return None,
268 };
269
270 let days = days_from_civil(year, month, day);
271 let secs_of_day = (hour as i64) * 3600 + (min as i64) * 60 + (sec as i64);
272 let unix = days * 86400 + secs_of_day - tz_secs;
273 if unix < 0 {
274 return None;
275 }
276 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(unix as u64))
277}
278
279fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
282 let y = (if month <= 2 { year - 1 } else { year }) as i64;
283 let era = (if y >= 0 { y } else { y - 399 }) / 400;
284 let yoe = y - era * 400;
285 let m = month as i64;
286 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day as i64 - 1;
287 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
288 era * 146097 + doe - 719468
289}
290
291impl Client {
292 pub async fn batch(&self, req: &BatchRequest) -> Result<BatchResponse, ApiError> {
294 let op = match req.operation {
298 Operation::Upload => crate::ssh::SshOperation::Upload,
299 Operation::Download => crate::ssh::SshOperation::Download,
300 };
301 self.post_json("objects/batch", req, op).await
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn operation_serializes_lowercase() {
311 let s = serde_json::to_string(&Operation::Download).unwrap();
312 assert_eq!(s, "\"download\"");
313 }
314
315 #[test]
316 fn request_skips_empty_optional_fields() {
317 let req = BatchRequest::new(
318 Operation::Download,
319 vec![ObjectSpec {
320 oid: "abc".into(),
321 size: 10,
322 }],
323 );
324 let v = serde_json::to_value(&req).unwrap();
325 assert!(v.get("transfers").is_none());
326 assert!(v.get("ref").is_none());
327 assert!(v.get("hash_algo").is_none());
328 }
329
330 #[test]
331 fn parses_canonical_download_response() {
332 let body = r#"{
333 "transfer": "basic",
334 "objects": [{
335 "oid": "1111111",
336 "size": 123,
337 "authenticated": true,
338 "actions": {
339 "download": {
340 "href": "https://some-download.com",
341 "header": { "Key": "value" },
342 "expires_at": "2016-11-10T15:29:07Z"
343 }
344 }
345 }],
346 "hash_algo": "sha256"
347 }"#;
348 let resp: BatchResponse = serde_json::from_str(body).unwrap();
349 assert_eq!(resp.transfer.as_deref(), Some("basic"));
350 let obj = &resp.objects[0];
351 assert_eq!(obj.authenticated, Some(true));
352 let action = obj.actions.as_ref().unwrap().download.as_ref().unwrap();
353 assert_eq!(action.href, "https://some-download.com");
354 assert_eq!(action.header.get("Key").unwrap(), "value");
355 assert!(action.expires_in.is_none());
356 }
357
358 #[test]
359 fn action_with_no_expiry_never_expires() {
360 let action = Action::default();
361 let action = Action {
365 href: "x".into(),
366 ..action
367 };
368 assert!(!action.is_expired_within(SystemTime::now(), Duration::from_secs(5)));
369 }
370
371 #[test]
372 fn action_with_negative_expires_in_is_expired() {
373 let action = Action {
374 href: "x".into(),
375 expires_in: Some(-5),
376 ..Default::default()
377 };
378 assert!(action.is_expired_within(SystemTime::now(), Duration::from_secs(5)));
379 }
380
381 #[test]
382 fn action_with_past_expires_at_is_expired() {
383 let action = Action {
384 href: "x".into(),
385 expires_at: Some("2016-11-10T15:29:07Z".into()),
386 ..Default::default()
387 };
388 assert!(action.is_expired_within(SystemTime::now(), Duration::from_secs(5)));
389 }
390
391 #[test]
392 fn action_with_far_future_expires_at_is_not_expired() {
393 let action = Action {
394 href: "x".into(),
395 expires_at: Some("2099-01-01T00:00:00Z".into()),
396 ..Default::default()
397 };
398 assert!(!action.is_expired_within(SystemTime::now(), Duration::from_secs(5)));
399 }
400
401 #[test]
402 fn action_expires_in_takes_precedence_over_expires_at() {
403 let action = Action {
405 href: "x".into(),
406 expires_in: Some(3600),
407 expires_at: Some("2016-11-10T15:29:07Z".into()),
408 ..Default::default()
409 };
410 assert!(!action.is_expired_within(SystemTime::now(), Duration::from_secs(5)));
411 }
412
413 #[test]
414 fn parses_per_object_error() {
415 let body = r#"{
416 "transfer": "basic",
417 "objects": [{
418 "oid": "1111111", "size": 123,
419 "error": { "code": 404, "message": "Object does not exist" }
420 }]
421 }"#;
422 let resp: BatchResponse = serde_json::from_str(body).unwrap();
423 let err = resp.objects[0].error.as_ref().unwrap();
424 assert_eq!(err.code, 404);
425 assert_eq!(err.message, "Object does not exist");
426 }
427
428 #[test]
429 fn parses_upload_already_present_no_actions() {
430 let body = r#"{
431 "objects": [{ "oid": "1111111", "size": 123 }]
432 }"#;
433 let resp: BatchResponse = serde_json::from_str(body).unwrap();
434 let obj = &resp.objects[0];
435 assert!(obj.actions.is_none());
436 assert!(obj.error.is_none());
437 }
438}