Skip to main content

git_lfs_api/
batch.rs

1//! Batch API: request the ability to transfer LFS objects.
2//!
3//! See `docs/api/batch.md` for the wire-protocol contract.
4
5use std::collections::HashMap;
6
7use serde::{Deserialize, Serialize};
8
9use crate::client::Client;
10use crate::error::ApiError;
11use crate::models::Ref;
12
13/// Operation requested from the batch endpoint.
14#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(rename_all = "lowercase")]
16pub enum Operation {
17    Download,
18    Upload,
19}
20
21/// One object the client wants to transfer.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct ObjectSpec {
24    pub oid: String,
25    pub size: u64,
26}
27
28/// A POST body for `/objects/batch`.
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct BatchRequest {
31    pub operation: Operation,
32    /// Transfer adapter identifiers the client supports. If empty, the spec
33    /// says the server MUST assume `basic`. We send the field unconditionally
34    /// so the server's preferred adapter is well-defined.
35    #[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    /// Optional hash algorithm. Defaults to `sha256` per the spec.
41    #[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/// Response body from `/objects/batch`.
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub struct BatchResponse {
70    /// Transfer adapter the server picked. `None` means the server omitted
71    /// it; per the spec the client should assume `basic`.
72    #[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/// Per-object result inside a batch response. Either `actions` or `error`
80/// is populated; both being absent means "server already has this object"
81/// (an upload no-op).
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83pub struct ObjectResult {
84    pub oid: String,
85    /// Size in bytes. Per the spec this is required, but the upstream
86    /// `lfstest-gitserver` (and at least one production server in the
87    /// wild) omit it on the action path — they assume the client
88    /// already knows. Default to 0 so we don't refuse the response;
89    /// callers that need the real size should look it up from the
90    /// matching request entry.
91    #[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/// Per-object error inside a batch response.
102///
103/// Codes mirror HTTP status codes per `docs/api/batch.md`:
104/// 404 = not found, 409 = hash-algo mismatch, 410 = removed, 422 = invalid.
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106pub struct ObjectError {
107    pub code: u32,
108    pub message: String,
109}
110
111/// The set of next-step actions the server returned for one object.
112///
113/// Field set depends on `operation`: `download` populates `download`;
114/// `upload` populates `upload` and optionally `verify`.
115#[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/// One concrete HTTP request the transfer adapter should make.
126#[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    /// Seconds until the action URL stops being valid. Preferred over
132    /// `expires_at` when both are given. Per the spec, range is roughly
133    /// ±2^31 seconds.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub expires_in: Option<i64>,
136    /// Absolute uppercase RFC 3339 timestamp at which the action URL stops
137    /// being valid. Carried as a string — see [`Lock`](crate::Lock).
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub expires_at: Option<String>,
140}
141
142impl Client {
143    /// POST `/objects/batch` to negotiate transfer URLs.
144    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 {
164                oid: "abc".into(),
165                size: 10,
166            }],
167        );
168        let v = serde_json::to_value(&req).unwrap();
169        assert!(v.get("transfers").is_none());
170        assert!(v.get("ref").is_none());
171        assert!(v.get("hash_algo").is_none());
172    }
173
174    #[test]
175    fn parses_canonical_download_response() {
176        let body = r#"{
177            "transfer": "basic",
178            "objects": [{
179                "oid": "1111111",
180                "size": 123,
181                "authenticated": true,
182                "actions": {
183                    "download": {
184                        "href": "https://some-download.com",
185                        "header": { "Key": "value" },
186                        "expires_at": "2016-11-10T15:29:07Z"
187                    }
188                }
189            }],
190            "hash_algo": "sha256"
191        }"#;
192        let resp: BatchResponse = serde_json::from_str(body).unwrap();
193        assert_eq!(resp.transfer.as_deref(), Some("basic"));
194        let obj = &resp.objects[0];
195        assert_eq!(obj.authenticated, Some(true));
196        let action = obj.actions.as_ref().unwrap().download.as_ref().unwrap();
197        assert_eq!(action.href, "https://some-download.com");
198        assert_eq!(action.header.get("Key").unwrap(), "value");
199        assert!(action.expires_in.is_none());
200    }
201
202    #[test]
203    fn parses_per_object_error() {
204        let body = r#"{
205            "transfer": "basic",
206            "objects": [{
207                "oid": "1111111", "size": 123,
208                "error": { "code": 404, "message": "Object does not exist" }
209            }]
210        }"#;
211        let resp: BatchResponse = serde_json::from_str(body).unwrap();
212        let err = resp.objects[0].error.as_ref().unwrap();
213        assert_eq!(err.code, 404);
214        assert_eq!(err.message, "Object does not exist");
215    }
216
217    #[test]
218    fn parses_upload_already_present_no_actions() {
219        let body = r#"{
220            "objects": [{ "oid": "1111111", "size": 123 }]
221        }"#;
222        let resp: BatchResponse = serde_json::from_str(body).unwrap();
223        let obj = &resp.objects[0];
224        assert!(obj.actions.is_none());
225        assert!(obj.error.is_none());
226    }
227}