1use std::collections::HashMap;
6
7use serde::{Deserialize, Serialize};
8
9use crate::client::Client;
10use crate::error::ApiError;
11use crate::models::Ref;
12
13#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(rename_all = "lowercase")]
16pub enum Operation {
17 Download,
18 Upload,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct ObjectSpec {
24 pub oid: String,
25 pub size: u64,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct BatchRequest {
31 pub operation: Operation,
32 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub transfers: Vec<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub r#ref: Option<Ref>,
39 pub objects: Vec<ObjectSpec>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub hash_algo: Option<String>,
43}
44
45impl BatchRequest {
46 pub fn new(operation: Operation, objects: Vec<ObjectSpec>) -> Self {
47 Self {
48 operation,
49 transfers: Vec::new(),
50 r#ref: None,
51 objects,
52 hash_algo: None,
53 }
54 }
55
56 pub fn with_transfers(mut self, transfers: impl IntoIterator<Item = String>) -> Self {
57 self.transfers = transfers.into_iter().collect();
58 self
59 }
60
61 pub fn with_ref(mut self, r: Ref) -> Self {
62 self.r#ref = Some(r);
63 self
64 }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub struct BatchResponse {
70 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub transfer: Option<String>,
74 pub objects: Vec<ObjectResult>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub hash_algo: Option<String>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83pub struct ObjectResult {
84 pub oid: String,
85 #[serde(default)]
92 pub size: u64,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub authenticated: Option<bool>,
95 #[serde(default, alias = "_links", skip_serializing_if = "Option::is_none")]
96 pub actions: Option<Actions>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub error: Option<ObjectError>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106pub struct ObjectError {
107 pub code: u32,
108 pub message: String,
109}
110
111#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
116pub struct Actions {
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub download: Option<Action>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub upload: Option<Action>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub verify: Option<Action>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
127pub struct Action {
128 pub href: String,
129 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
130 pub header: HashMap<String, String>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub expires_in: Option<i64>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub expires_at: Option<String>,
140}
141
142impl Client {
143 pub async fn batch(&self, req: &BatchRequest) -> Result<BatchResponse, ApiError> {
145 let op = match req.operation {
149 Operation::Upload => crate::ssh::SshOperation::Upload,
150 Operation::Download => crate::ssh::SshOperation::Download,
151 };
152 self.post_json("objects/batch", req, op).await
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn operation_serializes_lowercase() {
162 let s = serde_json::to_string(&Operation::Download).unwrap();
163 assert_eq!(s, "\"download\"");
164 }
165
166 #[test]
167 fn request_skips_empty_optional_fields() {
168 let req = BatchRequest::new(
169 Operation::Download,
170 vec![ObjectSpec {
171 oid: "abc".into(),
172 size: 10,
173 }],
174 );
175 let v = serde_json::to_value(&req).unwrap();
176 assert!(v.get("transfers").is_none());
177 assert!(v.get("ref").is_none());
178 assert!(v.get("hash_algo").is_none());
179 }
180
181 #[test]
182 fn parses_canonical_download_response() {
183 let body = r#"{
184 "transfer": "basic",
185 "objects": [{
186 "oid": "1111111",
187 "size": 123,
188 "authenticated": true,
189 "actions": {
190 "download": {
191 "href": "https://some-download.com",
192 "header": { "Key": "value" },
193 "expires_at": "2016-11-10T15:29:07Z"
194 }
195 }
196 }],
197 "hash_algo": "sha256"
198 }"#;
199 let resp: BatchResponse = serde_json::from_str(body).unwrap();
200 assert_eq!(resp.transfer.as_deref(), Some("basic"));
201 let obj = &resp.objects[0];
202 assert_eq!(obj.authenticated, Some(true));
203 let action = obj.actions.as_ref().unwrap().download.as_ref().unwrap();
204 assert_eq!(action.href, "https://some-download.com");
205 assert_eq!(action.header.get("Key").unwrap(), "value");
206 assert!(action.expires_in.is_none());
207 }
208
209 #[test]
210 fn parses_per_object_error() {
211 let body = r#"{
212 "transfer": "basic",
213 "objects": [{
214 "oid": "1111111", "size": 123,
215 "error": { "code": 404, "message": "Object does not exist" }
216 }]
217 }"#;
218 let resp: BatchResponse = serde_json::from_str(body).unwrap();
219 let err = resp.objects[0].error.as_ref().unwrap();
220 assert_eq!(err.code, 404);
221 assert_eq!(err.message, "Object does not exist");
222 }
223
224 #[test]
225 fn parses_upload_already_present_no_actions() {
226 let body = r#"{
227 "objects": [{ "oid": "1111111", "size": 123 }]
228 }"#;
229 let resp: BatchResponse = serde_json::from_str(body).unwrap();
230 let obj = &resp.objects[0];
231 assert!(obj.actions.is_none());
232 assert!(obj.error.is_none());
233 }
234}