Skip to main content

mdcast_api/wire/
blob.rs

1//! Blob negotiation: the exchange that keeps asset bytes off the render
2//! request. A client asks which digests the server is missing, uploads only
3//! those, and renders.
4
5use serde::{Deserialize, Serialize};
6
7use crate::digest::Digest;
8
9/// "Which of these do you already have?" — sent before a render (or before
10/// re-uploading a large asset set) to avoid a doomed round-trip.
11#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
12pub struct BlobCheckRequest {
13    pub digests: Vec<Digest>,
14}
15
16/// The subset of the requested digests the server does **not** hold. Empty
17/// means a render with that manifest will not stall on missing assets.
18#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
19pub struct BlobCheckResponse {
20    pub missing: Vec<Digest>,
21}
22
23/// One manifest entry the server cannot resolve. Carries the key as well as
24/// the digest so the client knows which of its own assets to upload without
25/// keeping a reverse index.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct MissingBlob {
28    pub key: String,
29    pub digest: Digest,
30}
31
32/// `409 Conflict` body from a render whose manifest references digests the
33/// server does not hold. This is the negotiation a client retries on: upload
34/// `missing`, then repeat the identical render request.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct MissingBlobsError {
37    pub message: String,
38    pub missing: Vec<MissingBlob>,
39}
40
41#[cfg(test)]
42mod tests {
43    use super::*;
44
45    fn digest(byte: u8) -> Digest {
46        Digest::from_sha256([byte; 32])
47    }
48
49    #[test]
50    fn blob_check_round_trips_through_json() {
51        let req = BlobCheckRequest {
52            digests: vec![digest(1), digest(2)],
53        };
54        let json = serde_json::to_string(&req).unwrap();
55        assert_eq!(
56            serde_json::from_str::<BlobCheckRequest>(&json).unwrap(),
57            req
58        );
59
60        let res = BlobCheckResponse {
61            missing: vec![digest(2)],
62        };
63        let json = serde_json::to_string(&res).unwrap();
64        assert_eq!(
65            serde_json::from_str::<BlobCheckResponse>(&json).unwrap(),
66            res
67        );
68    }
69
70    #[test]
71    fn empty_blob_check_response_means_nothing_is_missing() {
72        let res: BlobCheckResponse = serde_json::from_str(r#"{"missing":[]}"#).unwrap();
73        assert_eq!(res, BlobCheckResponse::default());
74        assert!(res.missing.is_empty());
75    }
76
77    #[test]
78    fn missing_blobs_error_round_trips_through_json() {
79        let err = MissingBlobsError {
80            message: "2 assets are not on the server".to_string(),
81            missing: vec![
82                MissingBlob {
83                    key: "img/logo.png".to_string(),
84                    digest: digest(1),
85                },
86                MissingBlob {
87                    key: "typst/layouts/pdf/hero.typ".to_string(),
88                    digest: digest(2),
89                },
90            ],
91        };
92
93        let json = serde_json::to_string(&err).unwrap();
94        assert_eq!(
95            serde_json::from_str::<MissingBlobsError>(&json).unwrap(),
96            err
97        );
98    }
99
100    #[test]
101    fn missing_blobs_error_rejects_an_invalid_digest() {
102        let err = serde_json::from_str::<MissingBlobsError>(
103            r#"{"message":"m","missing":[{"key":"k","digest":"NOPE"}]}"#,
104        )
105        .unwrap_err();
106        assert!(err.to_string().contains("64 hex characters"), "{err}");
107    }
108}