use std::collections::HashMap;
use std::time::{Duration, SystemTime};
use serde::{Deserialize, Deserializer, Serialize};
use crate::client::Client;
use crate::error::ApiError;
use crate::models::Ref;
fn deserialize_object_size<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
use serde::de::Error;
let v = i64::deserialize(d)?;
if v < 0 {
return Err(D::Error::custom(format!("invalid size (got: {v})")));
}
Ok(v as u64)
}
fn deserialize_optional_object_size<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
deserialize_object_size(d)
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Operation {
Download,
Upload,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectSpec {
pub oid: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BatchRequest {
pub operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub transfers: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub r#ref: Option<Ref>,
pub objects: Vec<ObjectSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hash_algo: Option<String>,
}
impl BatchRequest {
pub fn new(operation: Operation, objects: Vec<ObjectSpec>) -> Self {
Self {
operation,
transfers: Vec::new(),
r#ref: None,
objects,
hash_algo: None,
}
}
pub fn with_transfers(mut self, transfers: impl IntoIterator<Item = String>) -> Self {
self.transfers = transfers.into_iter().collect();
self
}
pub fn with_ref(mut self, r: Ref) -> Self {
self.r#ref = Some(r);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BatchResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transfer: Option<String>,
pub objects: Vec<ObjectResult>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hash_algo: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectResult {
pub oid: String,
#[serde(default, deserialize_with = "deserialize_optional_object_size")]
pub size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authenticated: Option<bool>,
#[serde(default, alias = "_links", skip_serializing_if = "Option::is_none")]
pub actions: Option<Actions>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<ObjectError>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectError {
pub code: u32,
pub message: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Actions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub download: Option<Action>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upload: Option<Action>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verify: Option<Action>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Action {
pub href: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub header: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_in: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
impl Action {
pub fn is_expired_within(&self, now: SystemTime, buffer: Duration) -> bool {
let expiration = match (self.expires_in, self.expires_at.as_deref()) {
(Some(secs), _) if secs != 0 => {
if secs < 0 {
SystemTime::UNIX_EPOCH
} else {
now.checked_add(Duration::from_secs(secs as u64))
.unwrap_or(SystemTime::UNIX_EPOCH)
}
}
(_, Some(s)) => match parse_rfc3339(s) {
Some(t) => t,
None => return false,
},
_ => return false,
};
expiration < now + buffer
}
}
fn parse_rfc3339(s: &str) -> Option<SystemTime> {
let bytes = s.as_bytes();
if bytes.len() < 20
|| bytes[4] != b'-'
|| bytes[7] != b'-'
|| bytes[10] != b'T'
|| bytes[13] != b':'
|| bytes[16] != b':'
{
return None;
}
let year: i32 = s.get(0..4)?.parse().ok()?;
let month: u32 = s.get(5..7)?.parse().ok()?;
let day: u32 = s.get(8..10)?.parse().ok()?;
let hour: u32 = s.get(11..13)?.parse().ok()?;
let min: u32 = s.get(14..16)?.parse().ok()?;
let sec: u32 = s.get(17..19)?.parse().ok()?;
let mut idx = 19;
if bytes.get(idx) == Some(&b'.') {
idx += 1;
while bytes.get(idx).is_some_and(|b| b.is_ascii_digit()) {
idx += 1;
}
}
let tz_secs: i64 = match bytes.get(idx) {
Some(b'Z') | Some(b'z') => 0,
Some(b'+') | Some(b'-') => {
let sign = if bytes[idx] == b'+' { 1 } else { -1 };
let h: i64 = s.get(idx + 1..idx + 3)?.parse().ok()?;
let m: i64 = s.get(idx + 4..idx + 6)?.parse().ok()?;
sign * (h * 3600 + m * 60)
}
_ => return None,
};
let days = days_from_civil(year, month, day);
let secs_of_day = (hour as i64) * 3600 + (min as i64) * 60 + (sec as i64);
let unix = days * 86400 + secs_of_day - tz_secs;
if unix < 0 {
return None;
}
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(unix as u64))
}
fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
let y = (if month <= 2 { year - 1 } else { year }) as i64;
let era = (if y >= 0 { y } else { y - 399 }) / 400;
let yoe = y - era * 400;
let m = month as i64;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
impl Client {
pub async fn batch(&self, req: &BatchRequest) -> Result<BatchResponse, ApiError> {
let op = match req.operation {
Operation::Upload => crate::ssh::SshOperation::Upload,
Operation::Download => crate::ssh::SshOperation::Download,
};
self.post_json("objects/batch", req, op).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn operation_serializes_lowercase() {
let s = serde_json::to_string(&Operation::Download).unwrap();
assert_eq!(s, "\"download\"");
}
#[test]
fn request_skips_empty_optional_fields() {
let req = BatchRequest::new(
Operation::Download,
vec![ObjectSpec {
oid: "abc".into(),
size: 10,
}],
);
let v = serde_json::to_value(&req).unwrap();
assert!(v.get("transfers").is_none());
assert!(v.get("ref").is_none());
assert!(v.get("hash_algo").is_none());
}
#[test]
fn parses_canonical_download_response() {
let body = r#"{
"transfer": "basic",
"objects": [{
"oid": "1111111",
"size": 123,
"authenticated": true,
"actions": {
"download": {
"href": "https://some-download.com",
"header": { "Key": "value" },
"expires_at": "2016-11-10T15:29:07Z"
}
}
}],
"hash_algo": "sha256"
}"#;
let resp: BatchResponse = serde_json::from_str(body).unwrap();
assert_eq!(resp.transfer.as_deref(), Some("basic"));
let obj = &resp.objects[0];
assert_eq!(obj.authenticated, Some(true));
let action = obj.actions.as_ref().unwrap().download.as_ref().unwrap();
assert_eq!(action.href, "https://some-download.com");
assert_eq!(action.header.get("Key").unwrap(), "value");
assert!(action.expires_in.is_none());
}
#[test]
fn action_with_no_expiry_never_expires() {
let action = Action::default();
let action = Action {
href: "x".into(),
..action
};
assert!(!action.is_expired_within(SystemTime::now(), Duration::from_secs(5)));
}
#[test]
fn action_with_negative_expires_in_is_expired() {
let action = Action {
href: "x".into(),
expires_in: Some(-5),
..Default::default()
};
assert!(action.is_expired_within(SystemTime::now(), Duration::from_secs(5)));
}
#[test]
fn action_with_past_expires_at_is_expired() {
let action = Action {
href: "x".into(),
expires_at: Some("2016-11-10T15:29:07Z".into()),
..Default::default()
};
assert!(action.is_expired_within(SystemTime::now(), Duration::from_secs(5)));
}
#[test]
fn action_with_far_future_expires_at_is_not_expired() {
let action = Action {
href: "x".into(),
expires_at: Some("2099-01-01T00:00:00Z".into()),
..Default::default()
};
assert!(!action.is_expired_within(SystemTime::now(), Duration::from_secs(5)));
}
#[test]
fn action_expires_in_takes_precedence_over_expires_at() {
let action = Action {
href: "x".into(),
expires_in: Some(3600),
expires_at: Some("2016-11-10T15:29:07Z".into()),
..Default::default()
};
assert!(!action.is_expired_within(SystemTime::now(), Duration::from_secs(5)));
}
#[test]
fn parses_per_object_error() {
let body = r#"{
"transfer": "basic",
"objects": [{
"oid": "1111111", "size": 123,
"error": { "code": 404, "message": "Object does not exist" }
}]
}"#;
let resp: BatchResponse = serde_json::from_str(body).unwrap();
let err = resp.objects[0].error.as_ref().unwrap();
assert_eq!(err.code, 404);
assert_eq!(err.message, "Object does not exist");
}
#[test]
fn parses_upload_already_present_no_actions() {
let body = r#"{
"objects": [{ "oid": "1111111", "size": 123 }]
}"#;
let resp: BatchResponse = serde_json::from_str(body).unwrap();
let obj = &resp.objects[0];
assert!(obj.actions.is_none());
assert!(obj.error.is_none());
}
}