Skip to main content

shardline_server/
model.rs

1use std::collections::BTreeMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5use shardline_protocol::{RepositoryProvider, SecretString, TokenScope};
6
7/// Health response returned by the HTTP server.
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9pub struct HealthResponse {
10    /// Service status.
11    pub status: String,
12}
13
14/// Readiness response returned by the HTTP server.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct ReadyResponse {
17    /// Service status.
18    pub status: String,
19    /// Selected runtime role.
20    pub server_role: String,
21    /// Enabled runtime protocol frontends.
22    pub server_frontends: Vec<String>,
23    /// Selected metadata backend.
24    pub metadata_backend: String,
25    /// Selected immutable object-storage backend.
26    pub object_backend: String,
27    /// Selected reconstruction-cache backend.
28    pub cache_backend: String,
29}
30
31/// Upload result for a single chunk.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct UploadChunkResult {
34    /// Chunk hash in Xet CAS API hexadecimal ordering.
35    pub hash: String,
36    /// Byte offset inside the uploaded file.
37    pub offset: u64,
38    /// Chunk byte length.
39    pub length: u64,
40    /// Whether the upload inserted new chunk bytes.
41    pub inserted: bool,
42}
43
44/// File upload response.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46pub struct UploadFileResponse {
47    /// Uploaded file identifier.
48    pub file_id: String,
49    /// Immutable content identity for this uploaded file version.
50    pub content_hash: String,
51    /// Total uploaded byte length.
52    pub total_bytes: u64,
53    /// Server chunk size used for this upload.
54    pub chunk_size: u64,
55    /// Number of chunks inserted.
56    pub inserted_chunks: u64,
57    /// Number of chunks already present.
58    pub reused_chunks: u64,
59    /// Number of new bytes written to chunk storage.
60    pub stored_bytes: u64,
61    /// Ordered chunk upload results.
62    pub chunks: Vec<UploadChunkResult>,
63}
64
65/// Storage stats response.
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct ServerStatsResponse {
68    /// Number of chunk objects stored.
69    pub chunks: u64,
70    /// Total bytes stored across chunk objects.
71    pub chunk_bytes: u64,
72    /// Number of file records stored.
73    pub files: u64,
74}
75
76/// Provider-backed CAS token issuance request.
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78pub struct ProviderTokenIssueRequest {
79    /// Authenticated provider subject to authorize.
80    pub subject: String,
81    /// Repository owner or namespace.
82    pub owner: String,
83    /// Repository name.
84    pub repo: String,
85    /// Optional revision context. When omitted, the provider default revision is used.
86    pub revision: Option<String>,
87    /// Requested CAS scope.
88    pub scope: TokenScope,
89}
90
91/// Provider-backed CAS token issuance response.
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93pub struct ProviderTokenIssueResponse {
94    /// Signed bearer token for subsequent CAS requests.
95    pub token: SecretString,
96    /// Issuer embedded into the token.
97    pub issuer: String,
98    /// Subject embedded into the token.
99    pub subject: String,
100    /// Repository hosting provider.
101    pub provider: RepositoryProvider,
102    /// Repository owner or namespace.
103    pub owner: String,
104    /// Repository name.
105    pub repo: String,
106    /// Scoped revision.
107    pub revision: Option<String>,
108    /// Granted CAS scope.
109    pub scope: TokenScope,
110    /// Token expiration timestamp as Unix seconds.
111    pub expires_at_unix_seconds: u64,
112}
113
114/// Xet CAS access-token response consumed by reference clients.
115#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
116#[serde(rename_all = "camelCase")]
117pub struct XetCasTokenResponse {
118    /// CAS server endpoint base URL.
119    pub cas_url: String,
120    /// Token expiration timestamp as Unix seconds.
121    pub exp: u64,
122    /// Signed bearer token for CAS requests.
123    pub access_token: String,
124}
125
126impl fmt::Debug for XetCasTokenResponse {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        f.debug_struct("XetCasTokenResponse")
129            .field("cas_url", &self.cas_url)
130            .field("exp", &self.exp)
131            .field("access_token", &"<redacted>")
132            .finish()
133    }
134}
135
136/// Git LFS authenticate response carrying Xet custom-transfer bootstrap headers.
137#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
138pub struct GitLfsAuthenticateResponse {
139    /// CAS endpoint URL.
140    pub href: String,
141    /// Headers consumed by the Xet custom transfer adapter.
142    pub header: BTreeMap<String, String>,
143    /// Relative token lifetime in seconds.
144    pub expires_in: u64,
145}
146
147impl fmt::Debug for GitLfsAuthenticateResponse {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        f.debug_struct("GitLfsAuthenticateResponse")
150            .field("href", &self.href)
151            .field("header", &"<redacted>")
152            .field("expires_in", &self.expires_in)
153            .finish()
154    }
155}
156
157/// OCI registry bearer-token exchange response.
158#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
159pub struct OciRegistryTokenResponse {
160    /// Bearer token returned by the registry token service.
161    pub token: String,
162    /// Duplicate bearer token field used by some clients.
163    pub access_token: String,
164    /// Relative token lifetime in seconds.
165    pub expires_in: u64,
166}
167
168impl fmt::Debug for OciRegistryTokenResponse {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        f.debug_struct("OciRegistryTokenResponse")
171            .field("token", &"<redacted>")
172            .field("access_token", &"<redacted>")
173            .field("expires_in", &self.expires_in)
174            .finish()
175    }
176}
177
178/// Provider webhook handling response.
179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
180pub struct ProviderWebhookResponse {
181    /// Repository hosting provider.
182    pub provider: RepositoryProvider,
183    /// Repository owner or namespace.
184    pub owner: String,
185    /// Repository name.
186    pub repo: String,
187    /// Provider delivery identifier.
188    pub delivery_id: String,
189    /// Normalized webhook event kind.
190    pub event_kind: String,
191    /// New repository owner or namespace when the event renamed the repository.
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub new_owner: Option<String>,
194    /// New repository name when the event renamed the repository.
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub new_repo: Option<String>,
197    /// Updated revision when the event described a push.
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub revision: Option<String>,
200    /// Number of affected immutable file-version records.
201    pub affected_file_versions: u64,
202    /// Number of distinct affected chunk objects.
203    pub affected_chunks: u64,
204    /// Number of retention holds inserted or refreshed by the event.
205    pub applied_holds: u64,
206    /// Retention applied to newly created holds, when the event mutated lifecycle state.
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub retention_seconds: Option<u64>,
209}
210
211#[cfg(test)]
212mod tests {
213    use std::collections::BTreeMap;
214
215    use shardline_protocol::{RepositoryProvider, SecretString, TokenScope};
216
217    use super::*;
218
219    fn health_response() -> HealthResponse {
220        HealthResponse {
221            status: "ok".to_owned(),
222        }
223    }
224
225    fn ready_response() -> ReadyResponse {
226        ReadyResponse {
227            status: "ok".to_owned(),
228            server_role: "all".to_owned(),
229            server_frontends: vec!["xet".to_owned()],
230            metadata_backend: "local".to_owned(),
231            object_backend: "local".to_owned(),
232            cache_backend: "memory".to_owned(),
233        }
234    }
235
236    #[test]
237    fn health_response_serde_round_trip() {
238        let original = health_response();
239        let json = serde_json::to_string(&original).unwrap();
240        let deserialized: HealthResponse = serde_json::from_str(&json).unwrap();
241        assert_eq!(original, deserialized);
242        assert!(json.contains("ok"));
243    }
244
245    #[test]
246    fn health_response_debug() {
247        let response = health_response();
248        let debug = format!("{response:?}");
249        assert!(debug.contains("HealthResponse"));
250        assert!(debug.contains("ok"));
251    }
252
253    #[test]
254    fn ready_response_serde_round_trip() {
255        let original = ready_response();
256        let json = serde_json::to_string(&original).unwrap();
257        let deserialized: ReadyResponse = serde_json::from_str(&json).unwrap();
258        assert_eq!(original, deserialized);
259    }
260
261    #[test]
262    fn ready_response_debug() {
263        let response = ready_response();
264        let debug = format!("{response:?}");
265        assert!(debug.contains("ReadyResponse"));
266    }
267
268    #[test]
269    fn upload_chunk_result_serde_round_trip() {
270        let original = UploadChunkResult {
271            hash: "abcdef123456".to_owned(),
272            offset: 0,
273            length: 4096,
274            inserted: true,
275        };
276        let json = serde_json::to_string(&original).unwrap();
277        let deserialized: UploadChunkResult = serde_json::from_str(&json).unwrap();
278        assert_eq!(original, deserialized);
279    }
280
281    #[test]
282    fn upload_file_response_serde_round_trip() {
283        let original = UploadFileResponse {
284            file_id: "test-file".to_owned(),
285            content_hash: "content-hash-abc".to_owned(),
286            total_bytes: 8192,
287            chunk_size: 4096,
288            inserted_chunks: 2,
289            reused_chunks: 0,
290            stored_bytes: 8192,
291            chunks: vec![UploadChunkResult {
292                hash: "chunk1".to_owned(),
293                offset: 0,
294                length: 4096,
295                inserted: true,
296            }],
297        };
298        let json = serde_json::to_string(&original).unwrap();
299        let deserialized: UploadFileResponse = serde_json::from_str(&json).unwrap();
300        assert_eq!(original, deserialized);
301    }
302
303    #[test]
304    fn server_stats_response_serde_round_trip() {
305        let original = ServerStatsResponse {
306            chunks: 10,
307            chunk_bytes: 40960,
308            files: 5,
309        };
310        let json = serde_json::to_string(&original).unwrap();
311        let deserialized: ServerStatsResponse = serde_json::from_str(&json).unwrap();
312        assert_eq!(original, deserialized);
313    }
314
315    #[test]
316    fn provider_token_issue_request_serde_round_trip() {
317        let original = ProviderTokenIssueRequest {
318            subject: "user".to_owned(),
319            owner: "org".to_owned(),
320            repo: "repo".to_owned(),
321            revision: Some("main".to_owned()),
322            scope: TokenScope::Read,
323        };
324        let json = serde_json::to_string(&original).unwrap();
325        let deserialized: ProviderTokenIssueRequest = serde_json::from_str(&json).unwrap();
326        assert_eq!(original, deserialized);
327    }
328
329    #[test]
330    fn provider_token_issue_response_serde_round_trip() {
331        let original = ProviderTokenIssueResponse {
332            token: SecretString::from_secret("bearer-token"),
333            issuer: "shardline".to_owned(),
334            subject: "user".to_owned(),
335            provider: RepositoryProvider::GitHub,
336            owner: "org".to_owned(),
337            repo: "repo".to_owned(),
338            revision: Some("main".to_owned()),
339            scope: TokenScope::Write,
340            expires_at_unix_seconds: 1_700_000_000,
341        };
342        let json = serde_json::to_string(&original).unwrap();
343        let deserialized: ProviderTokenIssueResponse = serde_json::from_str(&json).unwrap();
344        assert_eq!(original, deserialized);
345    }
346
347    #[test]
348    fn xet_cas_token_response_serde_round_trip() {
349        let original = XetCasTokenResponse {
350            cas_url: "http://cas.example.com".to_owned(),
351            exp: 1_700_000_000,
352            access_token: "access-token-value".to_owned(),
353        };
354        let json = serde_json::to_string(&original).unwrap();
355        let deserialized: XetCasTokenResponse = serde_json::from_str(&json).unwrap();
356        assert_eq!(original, deserialized);
357        // camelCase serialization
358        assert!(json.contains("casUrl"));
359        assert!(json.contains("accessToken"));
360    }
361
362    #[test]
363    fn git_lfs_authenticate_response_serde_round_trip() {
364        let mut header = BTreeMap::new();
365        header.insert("X-Custom".to_owned(), "value".to_owned());
366        let original = GitLfsAuthenticateResponse {
367            href: "http://lfs.example.com".to_owned(),
368            header,
369            expires_in: 3600,
370        };
371        let json = serde_json::to_string(&original).unwrap();
372        let deserialized: GitLfsAuthenticateResponse = serde_json::from_str(&json).unwrap();
373        assert_eq!(original, deserialized);
374    }
375
376    #[test]
377    fn oci_registry_token_response_serde_round_trip() {
378        let original = OciRegistryTokenResponse {
379            token: "bearer-token".to_owned(),
380            access_token: "access-token".to_owned(),
381            expires_in: 300,
382        };
383        let json = serde_json::to_string(&original).unwrap();
384        let deserialized: OciRegistryTokenResponse = serde_json::from_str(&json).unwrap();
385        assert_eq!(original, deserialized);
386    }
387
388    #[test]
389    fn provider_webhook_response_serde_round_trip() {
390        let original = ProviderWebhookResponse {
391            provider: RepositoryProvider::GitHub,
392            owner: "org".to_owned(),
393            repo: "repo".to_owned(),
394            delivery_id: "delivery-123".to_owned(),
395            event_kind: "revision_pushed".to_owned(),
396            new_owner: None,
397            new_repo: None,
398            revision: Some("abc123".to_owned()),
399            affected_file_versions: 5,
400            affected_chunks: 20,
401            applied_holds: 3,
402            retention_seconds: Some(86_400),
403        };
404        let json = serde_json::to_string(&original).unwrap();
405        let deserialized: ProviderWebhookResponse = serde_json::from_str(&json).unwrap();
406        assert_eq!(original, deserialized);
407    }
408
409    #[test]
410    fn provider_webhook_response_omits_optional_empty_fields() {
411        let original = ProviderWebhookResponse {
412            provider: RepositoryProvider::GitHub,
413            owner: "org".to_owned(),
414            repo: "repo".to_owned(),
415            delivery_id: "delivery-123".to_owned(),
416            event_kind: "push".to_owned(),
417            new_owner: None,
418            new_repo: None,
419            revision: None,
420            affected_file_versions: 0,
421            affected_chunks: 0,
422            applied_holds: 0,
423            retention_seconds: None,
424        };
425        let json = serde_json::to_string(&original).unwrap();
426        // Fields with skip_serializing_if should be absent
427        assert!(
428            !json.contains("\"new_owner\""),
429            "unexpected new_owner: {json}"
430        );
431        assert!(
432            !json.contains("\"new_repo\""),
433            "unexpected new_repo: {json}"
434        );
435        assert!(
436            !json.contains("\"revision\""),
437            "unexpected revision: {json}"
438        );
439        assert!(
440            !json.contains("\"retention_seconds\""),
441            "unexpected retention_seconds: {json}"
442        );
443    }
444}