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        // Match the SSH `git-lfs-authenticate` operation to the batch
146        // operation: an upload batch needs upload-scoped auth, a
147        // download batch needs download-scoped auth.
148        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}