Skip to main content

ahri_tre_protocol/
datafile.rs

1use crate::{
2    ProtocolVersion, PublicName,
3    asset::{AssetCatalog, SafeAssetCatalog},
4    pagination::PageRequest,
5    public_error::{ProtocolError, ValidationIssue, ValidationIssueCode},
6    refs::{AssetRef, DataFileRef, SessionSelector, StudyRef, TagSummary, VersionRef},
7    search::{PredicateSet, SearchCursorBinding, TagPredicate, TextPredicate},
8    study::StudySelector,
9    tabular::TabularDataRef,
10};
11
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
16pub enum DataFileSelector {
17    Id {
18        datafile: DataFileRef,
19        #[serde(default, skip_serializing_if = "Option::is_none")]
20        study: Option<StudySelector>,
21    },
22    Name {
23        study: StudySelector,
24        name: PublicName,
25    },
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct DataFileVersionRef {
30    pub datafile: DataFileRef,
31    pub version: VersionRef,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct DataFileSummary {
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub version: Option<VersionRef>,
38    pub datafile: DataFileRef,
39    pub study: StudyRef,
40    pub name: String,
41    pub format: String,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub description: Option<String>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub size_bytes: Option<u64>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub digest_ref: Option<String>,
48    #[serde(default)]
49    pub tags: Vec<TagSummary>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct DataFileDetail {
54    pub summary: DataFileSummary,
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    pub tabular_refs: Vec<TabularDataRef>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct ListDataFilesRequest {
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub session: Option<SessionSelector>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub study: Option<StudySelector>,
65    pub page: PageRequest,
66    #[serde(default)]
67    pub include_versions: bool,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct GetDataFileMetadataRequest {
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub session: Option<SessionSelector>,
74    pub datafile: DataFileSelector,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub version: Option<String>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct ExportDataFileRequest {
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub session: Option<SessionSelector>,
83    pub datafile: DataFileSelector,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub version: Option<String>,
86    pub target: ExportTarget,
87    #[serde(default)]
88    pub overwrite: bool,
89    #[serde(default)]
90    pub compress: bool,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct ExportTarget {
95    pub path: String,
96    #[serde(default)]
97    pub request_only: bool,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct SearchDataFilesRequest {
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub session: Option<SessionSelector>,
104    pub predicates: PredicateSet<DataFilePredicate>,
105    pub page: PageRequest,
106}
107
108/// Service-authorized browser Datafile catalogue search scoped to one public
109/// Datastore identity. The nested search retains the established browser
110/// validation and cursor contract without carrying a user Session.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct BrowserDataFileSearchRequest {
113    pub datastore_id: crate::PublicUuid,
114    pub search: SearchDataFilesRequest,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct DataFileSearchCursorAnchor {
119    pub study: StudyRef,
120    pub datafile: DataFileRef,
121    pub name: String,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct DataFileSearchCursorQuery {
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub session: Option<SessionSelector>,
128    pub predicates: PredicateSet<DataFilePredicate>,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct DataFileSearchCursor {
133    pub binding: SearchCursorBinding<DataFileSearchCursorQuery>,
134    pub after: DataFileSearchCursorAnchor,
135}
136
137#[allow(clippy::result_large_err)]
138impl SearchDataFilesRequest {
139    pub fn validate(&mut self, protocol_version: &ProtocolVersion) -> Result<(), ProtocolError> {
140        let mut issues = Vec::new();
141        collect_predicate_set_issues("body.predicates", &self.predicates, &mut issues);
142        collect_datafile_predicate_issues(&mut self.predicates, &mut issues);
143        collect_page_issues("body.page.limit", self.page.limit, &mut issues);
144        collect_cursor_issues(self, protocol_version, &mut issues);
145
146        if issues.is_empty() {
147            Ok(())
148        } else {
149            Err(ProtocolError::validation(
150                "datafile search request is invalid",
151                issues,
152            ))
153        }
154    }
155
156    pub fn decoded_cursor(
157        &self,
158        protocol_version: &ProtocolVersion,
159    ) -> Result<Option<DataFileSearchCursorAnchor>, ProtocolError> {
160        match self.page.cursor.as_deref() {
161            None => Ok(None),
162            Some(raw_cursor) => {
163                let cursor: DataFileSearchCursor =
164                    serde_json::from_str(raw_cursor).map_err(|error| {
165                        ProtocolError::validation(
166                            "datafile search request is invalid",
167                            vec![ValidationIssue::new(
168                                "body.page.cursor",
169                                ValidationIssueCode::InvalidFormat,
170                                format!("datafile search cursor is invalid: {error}"),
171                            )],
172                        )
173                    })?;
174                let expected = self.cursor_binding(protocol_version);
175                if cursor.binding != expected {
176                    return Err(ProtocolError::validation(
177                        "datafile search request is invalid",
178                        vec![ValidationIssue::new(
179                            "body.page.cursor",
180                            ValidationIssueCode::Conflict,
181                            "datafile search cursor does not match the request predicates, page limit, or protocol version",
182                        )],
183                    ));
184                }
185                Ok(Some(cursor.after))
186            }
187        }
188    }
189
190    pub fn encode_cursor(
191        &self,
192        protocol_version: &ProtocolVersion,
193        after: DataFileSearchCursorAnchor,
194    ) -> Result<String, ProtocolError> {
195        serde_json::to_string(&DataFileSearchCursor {
196            binding: self.cursor_binding(protocol_version),
197            after,
198        })
199        .map_err(|error| {
200            ProtocolError::new(
201                crate::public_error::ProtocolErrorCode::Internal,
202                format!("failed to encode datafile search cursor: {error}"),
203            )
204        })
205    }
206
207    fn cursor_binding(
208        &self,
209        protocol_version: &ProtocolVersion,
210    ) -> SearchCursorBinding<DataFileSearchCursorQuery> {
211        SearchCursorBinding {
212            query: DataFileSearchCursorQuery {
213                session: self.session.clone(),
214                predicates: self.predicates.clone(),
215            },
216            page_limit: self.page.limit,
217            protocol_version: protocol_version.to_string(),
218        }
219    }
220
221    /// Validates the browser route variant of Datafile search. Browser callers
222    /// never hold daemon session capabilities and browser cursors are scoped
223    /// to the selected datastore and search family.
224    #[allow(clippy::result_large_err)]
225    pub fn validate_for_browser(
226        &mut self,
227        datastore_id: crate::PublicUuid,
228        protocol_version: &ProtocolVersion,
229    ) -> Result<(), ProtocolError> {
230        let mut issues = Vec::new();
231        if self.session.is_some() {
232            issues.push(ValidationIssue::new(
233                "body.session",
234                ValidationIssueCode::Unsupported,
235                "daemon session is not accepted for browser datafile search",
236            ));
237        }
238        collect_predicate_set_issues("body.predicates", &self.predicates, &mut issues);
239        collect_datafile_predicate_issues(&mut self.predicates, &mut issues);
240        collect_page_issues("body.page.limit", self.page.limit, &mut issues);
241        if let Some(raw_cursor) = self.page.cursor.as_deref() {
242            match serde_json::from_str::<BrowserDataFileSearchCursor>(raw_cursor) {
243                Ok(cursor)
244                    if cursor.binding
245                        == self.browser_cursor_binding(datastore_id, protocol_version) => {}
246                Ok(_) => issues.push(ValidationIssue::new(
247                    "body.page.cursor",
248                    ValidationIssueCode::Conflict,
249                    "browser datafile search cursor does not match the datastore, search family, predicates, page limit, or protocol version",
250                )),
251                Err(_) => issues.push(ValidationIssue::new(
252                    "body.page.cursor",
253                    ValidationIssueCode::InvalidFormat,
254                    "browser datafile search cursor is invalid",
255                )),
256            }
257        }
258        if issues.is_empty() {
259            Ok(())
260        } else {
261            Err(ProtocolError::validation(
262                "browser datafile search request is invalid",
263                issues,
264            ))
265        }
266    }
267
268    #[allow(clippy::result_large_err)]
269    pub fn browser_decoded_cursor(
270        &self,
271        datastore_id: crate::PublicUuid,
272        protocol_version: &ProtocolVersion,
273    ) -> Result<Option<BrowserDataFileSearchCursorAnchor>, ProtocolError> {
274        let Some(raw_cursor) = self.page.cursor.as_deref() else {
275            return Ok(None);
276        };
277        let cursor: BrowserDataFileSearchCursor =
278            serde_json::from_str(raw_cursor).map_err(|_| {
279                ProtocolError::validation(
280                    "browser datafile search request is invalid",
281                    vec![ValidationIssue::new(
282                        "body.page.cursor",
283                        ValidationIssueCode::InvalidFormat,
284                        "browser datafile search cursor is invalid",
285                    )],
286                )
287            })?;
288        if cursor.binding != self.browser_cursor_binding(datastore_id, protocol_version) {
289            return Err(ProtocolError::validation(
290                "browser datafile search request is invalid",
291                vec![ValidationIssue::new(
292                    "body.page.cursor",
293                    ValidationIssueCode::Conflict,
294                    "browser datafile search cursor does not match the datastore, search family, predicates, page limit, or protocol version",
295                )],
296            ));
297        }
298        Ok(Some(cursor.after))
299    }
300
301    #[allow(clippy::result_large_err)]
302    pub fn encode_browser_cursor(
303        &self,
304        datastore_id: crate::PublicUuid,
305        protocol_version: &ProtocolVersion,
306        after: BrowserDataFileSearchCursorAnchor,
307    ) -> Result<String, ProtocolError> {
308        serde_json::to_string(&BrowserDataFileSearchCursor {
309            binding: self.browser_cursor_binding(datastore_id, protocol_version),
310            after,
311        })
312        .map_err(|_| {
313            ProtocolError::new(
314                crate::public_error::ProtocolErrorCode::Internal,
315                "failed to encode browser datafile search cursor",
316            )
317        })
318    }
319
320    fn browser_cursor_binding(
321        &self,
322        datastore_id: crate::PublicUuid,
323        protocol_version: &ProtocolVersion,
324    ) -> SearchCursorBinding<BrowserDataFileSearchCursorQuery> {
325        SearchCursorBinding {
326            query: BrowserDataFileSearchCursorQuery {
327                datastore_id,
328                family: "datafiles".to_string(),
329                predicates: self.predicates.clone(),
330            },
331            page_limit: self.page.limit,
332            protocol_version: protocol_version.to_string(),
333        }
334    }
335}
336
337/// A browser-safe Datafile search page. Results reuse the existing Discovery
338/// record shape so descriptive size and storage-state facts may be returned
339/// while file bytes, digests, paths, and daemon state remain outside the Web
340/// contract.
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342pub struct BrowserDataFileSearchResponse {
343    pub protocol_version: ProtocolVersion,
344    pub datastore_id: crate::PublicUuid,
345    pub family: String,
346    pub predicates: PredicateSet<DataFilePredicate>,
347    #[serde(default)]
348    pub datafiles: Vec<crate::resource_discovery::BrowserDataFileDiscoveryRecord>,
349    pub returned_count: u16,
350    pub requested_limit: u16,
351    pub ordering: crate::study::BrowserSearchOrdering,
352    pub has_more: bool,
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub next_cursor: Option<String>,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub empty_state: Option<BrowserDataFileSearchEmptyState>,
357}
358
359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
360pub struct BrowserDataFileSearchEmptyState {
361    pub code: String,
362    pub message: String,
363}
364
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366pub struct BrowserDataFileSearchCursorQuery {
367    pub datastore_id: crate::PublicUuid,
368    pub family: String,
369    pub predicates: PredicateSet<DataFilePredicate>,
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373pub struct BrowserDataFileSearchCursor {
374    pub binding: SearchCursorBinding<BrowserDataFileSearchCursorQuery>,
375    pub after: BrowserDataFileSearchCursorAnchor,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379pub struct BrowserDataFileSearchCursorAnchor {
380    pub study: StudyRef,
381    pub datafile: DataFileRef,
382    pub asset: AssetRef,
383    pub name: String,
384}
385
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387#[serde(tag = "kind", rename_all = "snake_case")]
388pub enum DataFilePredicate {
389    Text(TextPredicate<DataFileTextField>),
390    Study { study: StudySelector },
391    Format { format: String },
392    Tag(TagPredicate),
393}
394
395#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
396#[serde(rename_all = "snake_case")]
397pub enum DataFileTextField {
398    Name,
399    Description,
400    Format,
401}
402
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404pub struct DataFileEntryPayload {
405    pub catalog: SafeAssetCatalog,
406    pub datafiles: Vec<SafeDataFileVersionSummary>,
407}
408
409/// Safe Datafile-version facts for catalogue listing.
410///
411/// Storage locations, digests, encryption keys, bytes, and previews are not
412/// part of this projection.
413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
414pub struct SafeDataFileVersionSummary {
415    pub datafile_id: VersionRef,
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub compressed: Option<bool>,
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub encrypted: Option<bool>,
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub compression_algorithm: Option<String>,
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub encryption_algorithm: Option<String>,
424    pub edam_format: String,
425}
426
427#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
428pub struct DataFileListResponse {
429    pub session: crate::session::SessionStatusPayload,
430    pub study: String,
431    pub study_id: StudyRef,
432    pub datafiles: Vec<DataFileEntryPayload>,
433}
434
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436pub struct DataFileSearchResponse {
437    pub session: crate::session::SessionStatusPayload,
438    pub datafiles: Vec<DataFileSummary>,
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub next_cursor: Option<String>,
441}
442
443#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
444pub struct DataFileMetadataPayload {
445    pub catalog: SafeAssetCatalog,
446    pub version: crate::asset::SafeAssetVersionSummary,
447    pub datafile: SafeDataFileVersionSummary,
448}
449
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
451pub struct DataFileMetadataResponse {
452    pub session: crate::session::SessionStatusPayload,
453    pub study: String,
454    pub metadata: DataFileMetadataPayload,
455}
456
457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
458pub struct DataFileExportResponse {
459    pub session: crate::session::SessionStatusPayload,
460    pub study: String,
461    pub catalog: AssetCatalog,
462    pub datafile: DataFileSummary,
463    pub lineage: crate::transformation::TransformationSummary,
464    pub artifact: ExportedArtifactSummary,
465    #[serde(default, skip_serializing_if = "Vec::is_empty")]
466    pub warnings: Vec<String>,
467}
468
469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
470pub struct ExportedArtifactSummary {
471    pub kind: ExportArtifactKind,
472    pub format: String,
473    pub compressed: bool,
474    pub stored_size_bytes: u64,
475    pub logical_size_bytes: u64,
476    pub exported_size_bytes: u64,
477    #[serde(default)]
478    pub target_request_only: bool,
479}
480
481#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
482#[serde(rename_all = "snake_case")]
483pub enum ExportArtifactKind {
484    DataFile,
485}
486
487fn collect_predicate_set_issues<P>(
488    path: &str,
489    predicates: &PredicateSet<P>,
490    issues: &mut Vec<ValidationIssue>,
491) {
492    if let Err(message) = predicates.validate() {
493        issues.push(ValidationIssue::new(
494            path,
495            ValidationIssueCode::InvalidValue,
496            message,
497        ));
498    }
499}
500
501fn collect_datafile_predicate_issues(
502    predicates: &mut PredicateSet<DataFilePredicate>,
503    issues: &mut Vec<ValidationIssue>,
504) {
505    for (index, predicate) in predicates.predicates_mut().iter_mut().enumerate() {
506        match predicate {
507            DataFilePredicate::Text(text) => {
508                if let Err(message) = text.validate() {
509                    issues.push(ValidationIssue::new(
510                        format!("body.predicates.predicates[{index}].value"),
511                        ValidationIssueCode::InvalidValue,
512                        message,
513                    ));
514                }
515            }
516            DataFilePredicate::Format { format } => {
517                *format = format.trim().to_string();
518                if format.is_empty() {
519                    issues.push(ValidationIssue::new(
520                        format!("body.predicates.predicates[{index}].format"),
521                        ValidationIssueCode::TooShort,
522                        "datafile format must not be empty",
523                    ));
524                } else if format.len() > crate::search::MAX_TEXT_VALUE_LENGTH {
525                    issues.push(ValidationIssue::new(
526                        format!("body.predicates.predicates[{index}].format"),
527                        ValidationIssueCode::TooLong,
528                        format!(
529                            "datafile format must be at most {} characters",
530                            crate::search::MAX_TEXT_VALUE_LENGTH
531                        ),
532                    ));
533                }
534            }
535            DataFilePredicate::Study { .. } | DataFilePredicate::Tag(_) => {}
536        }
537    }
538}
539
540fn collect_page_issues(path: &str, limit: u16, issues: &mut Vec<ValidationIssue>) {
541    if limit == 0 || limit > crate::search::MAX_PAGE_LIMIT {
542        issues.push(ValidationIssue::new(
543            path,
544            ValidationIssueCode::OutOfRange,
545            if limit == 0 {
546                "page limit must be greater than zero".to_string()
547            } else {
548                format!(
549                    "page limit must be at most {}",
550                    crate::search::MAX_PAGE_LIMIT
551                )
552            },
553        ));
554    }
555}
556
557fn collect_cursor_issues(
558    request: &SearchDataFilesRequest,
559    protocol_version: &ProtocolVersion,
560    issues: &mut Vec<ValidationIssue>,
561) {
562    if let Some(raw_cursor) = request.page.cursor.as_deref() {
563        let cursor: DataFileSearchCursor = match serde_json::from_str(raw_cursor) {
564            Ok(cursor) => cursor,
565            Err(error) => {
566                issues.push(ValidationIssue::new(
567                    "body.page.cursor",
568                    ValidationIssueCode::InvalidFormat,
569                    format!("datafile search cursor is invalid: {error}"),
570                ));
571                return;
572            }
573        };
574
575        if cursor.binding != request.cursor_binding(protocol_version) {
576            issues.push(ValidationIssue::new(
577                "body.page.cursor",
578                ValidationIssueCode::Conflict,
579                "datafile search cursor does not match the request predicates, page limit, or protocol version",
580            ));
581        }
582    }
583}