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, 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 self.post_json("objects/batch", req).await
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn operation_serializes_lowercase() {
155 let s = serde_json::to_string(&Operation::Download).unwrap();
156 assert_eq!(s, "\"download\"");
157 }
158
159 #[test]
160 fn request_skips_empty_optional_fields() {
161 let req = BatchRequest::new(
162 Operation::Download,
163 vec![ObjectSpec { oid: "abc".into(), size: 10 }],
164 );
165 let v = serde_json::to_value(&req).unwrap();
166 assert!(v.get("transfers").is_none());
167 assert!(v.get("ref").is_none());
168 assert!(v.get("hash_algo").is_none());
169 }
170
171 #[test]
172 fn parses_canonical_download_response() {
173 let body = r#"{
174 "transfer": "basic",
175 "objects": [{
176 "oid": "1111111",
177 "size": 123,
178 "authenticated": true,
179 "actions": {
180 "download": {
181 "href": "https://some-download.com",
182 "header": { "Key": "value" },
183 "expires_at": "2016-11-10T15:29:07Z"
184 }
185 }
186 }],
187 "hash_algo": "sha256"
188 }"#;
189 let resp: BatchResponse = serde_json::from_str(body).unwrap();
190 assert_eq!(resp.transfer.as_deref(), Some("basic"));
191 let obj = &resp.objects[0];
192 assert_eq!(obj.authenticated, Some(true));
193 let action = obj.actions.as_ref().unwrap().download.as_ref().unwrap();
194 assert_eq!(action.href, "https://some-download.com");
195 assert_eq!(action.header.get("Key").unwrap(), "value");
196 assert!(action.expires_in.is_none());
197 }
198
199 #[test]
200 fn parses_per_object_error() {
201 let body = r#"{
202 "transfer": "basic",
203 "objects": [{
204 "oid": "1111111", "size": 123,
205 "error": { "code": 404, "message": "Object does not exist" }
206 }]
207 }"#;
208 let resp: BatchResponse = serde_json::from_str(body).unwrap();
209 let err = resp.objects[0].error.as_ref().unwrap();
210 assert_eq!(err.code, 404);
211 assert_eq!(err.message, "Object does not exist");
212 }
213
214 #[test]
215 fn parses_upload_already_present_no_actions() {
216 let body = r#"{
217 "objects": [{ "oid": "1111111", "size": 123 }]
218 }"#;
219 let resp: BatchResponse = serde_json::from_str(body).unwrap();
220 let obj = &resp.objects[0];
221 assert!(obj.actions.is_none());
222 assert!(obj.error.is_none());
223 }
224}