Skip to main content

ahri_tre_app/service/
catalogue.rs

1use super::*;
2use ahri_tre_protocol::{
3    PublicUuid,
4    refs::{ObjectKind, ObjectRef},
5};
6
7impl AppService {
8    /// Durable origin of the retained, authenticated metadata capability.
9    pub fn catalogue_datastore_id(&self) -> Result<PublicUuid, AppError> {
10        self.datastore_id
11            .map(PublicUuid::from_uuid)
12            .ok_or_else(|| AppError::Validation("A retained Datastore binding is required".into()))
13    }
14
15    /// Checks origin and kind before any metadata lookup. A reference is never
16    /// authority: callers still resolve it through the authenticated repositories.
17    pub fn catalogue_reference_id(
18        &self,
19        reference: ObjectRef,
20        kind: ObjectKind,
21    ) -> Result<PublicUuid, AppError> {
22        if reference.kind != kind {
23            return Err(AppError::Validation(
24                "Object reference has the wrong kind".into(),
25            ));
26        }
27        if reference.datastore_id != self.catalogue_datastore_id()? {
28            return Err(AppError::Conflict(
29                "Object reference belongs to another Datastore".into(),
30            ));
31        }
32        Ok(reference.id)
33    }
34
35    pub fn catalogue_domain_selector(
36        &self,
37        selector: ahri_tre_protocol::domain::DomainSelector,
38    ) -> Result<DomainSelector, AppError> {
39        Ok(match selector {
40            ahri_tre_protocol::domain::DomainSelector::Name { name } => {
41                DomainSelector::Name { name: name.into() }
42            }
43            ahri_tre_protocol::domain::DomainSelector::Id { domain } => DomainSelector::Id {
44                domain_id: ahri_tre_types::DomainId(
45                    ahri_tre_protocol::refs::decode_scoped_integer_ref(
46                        "domain",
47                        self.catalogue_reference_id(domain, ObjectKind::Domain)?,
48                    )
49                    .ok_or_else(|| AppError::Validation("Invalid Domain reference".into()))?,
50                ),
51            },
52        })
53    }
54
55    pub fn catalogue_study_selector(
56        &self,
57        selector: ahri_tre_protocol::study::StudySelector,
58    ) -> Result<StudySelector, AppError> {
59        Ok(match selector {
60            ahri_tre_protocol::study::StudySelector::Id { study } => StudySelector::Id {
61                study_id: StudyId(
62                    self.catalogue_reference_id(study, ObjectKind::Study)?
63                        .as_uuid(),
64                ),
65            },
66            ahri_tre_protocol::study::StudySelector::Name { domain, name } => StudySelector::Name {
67                name: name.into(),
68                domain: domain
69                    .map(|domain| self.catalogue_domain_selector(domain))
70                    .transpose()?,
71            },
72        })
73    }
74
75    pub async fn add_catalogue_study(
76        &self,
77        request: ahri_tre_protocol::study::AddStudyRequest,
78    ) -> Result<StudyAddResult, AppError> {
79        use ahri_tre_protocol::study::StudyTypeSelector;
80        let domain = self.catalogue_domain_selector(request.domain)?;
81        let type_id = match request.study_type {
82            StudyTypeSelector::Reference(reference) => ahri_tre_types::StudyTypeId(
83                ahri_tre_protocol::refs::decode_scoped_integer_ref(
84                    "study_type",
85                    self.catalogue_reference_id(reference, ObjectKind::StudyType)?,
86                )
87                .ok_or_else(|| AppError::Validation("Invalid Study type reference".into()))?,
88            ),
89            StudyTypeSelector::Symbol(symbol) => {
90                if symbol.trim().parse::<i64>().is_ok() {
91                    return Err(AppError::Validation(
92                        "Numeric Study type selectors are not supported".into(),
93                    ));
94                }
95                parse_study_type_id(&symbol)?
96            }
97        };
98        if !self
99            .studies
100            .study_type_exists(type_id)
101            .await
102            .map_err(|error| core_error("lookup Study type", error))?
103        {
104            return Err(AppError::NotFound("Study type was not found".into()));
105        }
106        self.add_study(AddStudyRequest {
107            name: request.name.into(),
108            external_id: request.external_id,
109            study_type: type_id.0.to_string(),
110            domain,
111            description: request.description,
112        })
113        .await
114    }
115
116    pub async fn resolve_catalogue_asset(
117        &self,
118        selector: ahri_tre_protocol::asset::AssetSelector,
119    ) -> Result<
120        (
121            StudyRecord,
122            AssetVersionCatalog,
123            Option<ahri_tre_types::VersionId>,
124        ),
125        AppError,
126    > {
127        use ahri_tre_protocol::asset::AssetSelector;
128        // Validate every supplied reference before opening any catalogue row.
129        match &selector {
130            AssetSelector::Id {
131                study: Some(study), ..
132            }
133            | AssetSelector::Name { study, .. } => {
134                self.catalogue_study_selector(study.clone())?;
135            }
136            _ => {}
137        }
138        let (asset, asset_type, constraint, pinned) = match selector {
139            AssetSelector::Id {
140                asset,
141                asset_type,
142                study,
143            } => {
144                let (id, pinned) = if asset.kind == ObjectKind::AssetVersion {
145                    let version_id = ahri_tre_types::VersionId(
146                        self.catalogue_reference_id(asset, ObjectKind::AssetVersion)?
147                            .as_uuid(),
148                    );
149                    let version = self
150                        .assets
151                        .get_asset_version(version_id)
152                        .await
153                        .map_err(|error| core_error("lookup Asset version", error))?
154                        .ok_or_else(|| {
155                            AppError::NotFound(
156                                "Asset version was not found or is not visible".into(),
157                            )
158                        })?;
159                    (version.asset_id, Some(version_id))
160                } else {
161                    (
162                        ahri_tre_types::AssetId(
163                            self.catalogue_reference_id(asset, ObjectKind::Asset)?
164                                .as_uuid(),
165                        ),
166                        None,
167                    )
168                };
169                (
170                    self.assets
171                        .get_asset_by_id(id)
172                        .await
173                        .map_err(|error| core_error("lookup Asset", error))?,
174                    asset_type,
175                    study,
176                    pinned,
177                )
178            }
179            AssetSelector::Name {
180                study,
181                name,
182                asset_type,
183            } => {
184                let study = self
185                    .resolve_study_selector(self.catalogue_study_selector(study)?, "Asset Study")
186                    .await?;
187                let name = ahri_tre_types::NcName::parse(name.as_str().to_owned())
188                    .map_err(|_| AppError::Validation("Invalid Asset name".into()))?;
189                (
190                    self.assets
191                        .get_asset_by_name(study.study_id, name.as_str())
192                        .await
193                        .map_err(|error| core_error("lookup Asset", error))?,
194                    asset_type,
195                    None,
196                    None,
197                )
198            }
199        };
200        let asset = asset
201            .ok_or_else(|| AppError::NotFound("Asset was not found or is not visible".into()))?;
202        let study = self
203            .resolve_study_selector(
204                StudySelector::Id {
205                    study_id: asset.study_id,
206                },
207                "Asset Study",
208            )
209            .await?;
210        if let Some(constraint) = constraint {
211            let parent = self
212                .resolve_study_selector(
213                    self.catalogue_study_selector(constraint)?,
214                    "Asset Study constraint",
215                )
216                .await?;
217            if parent.study_id != study.study_id {
218                return Err(AppError::Conflict("Study selectors disagree".into()));
219            }
220        }
221        if asset_type.is_some_and(|kind| kind != asset.asset_type) {
222            return Err(AppError::Conflict(
223                "Asset subtype disagrees with selector".into(),
224            ));
225        }
226        Ok((study, self.asset_version_catalog(asset).await?, pinned))
227    }
228
229    pub(super) fn catalogue_version(
230        &self,
231        catalog: &AssetVersionCatalog,
232        pinned: Option<ahri_tre_types::VersionId>,
233        selector: Option<&str>,
234    ) -> Result<ahri_tre_types::AssetVersionRecord, AppError> {
235        let pinned = pinned
236            .map(|id| {
237                catalog
238                    .versions
239                    .iter()
240                    .find(|version| version.version_id == id)
241                    .cloned()
242                    .ok_or_else(|| AppError::NotFound("Asset version is not available".into()))
243            })
244            .transpose()?;
245        if let (Some(pinned), None) = (&pinned, selector) {
246            return Ok(pinned.clone());
247        }
248        let text = selector.map(str::trim).unwrap_or("latest");
249        if text.is_empty()
250            || (!text.eq_ignore_ascii_case("latest") && parse_semver_selector(text).is_none())
251        {
252            return Err(AppError::Validation("Invalid version selector".into()));
253        }
254        let selected = resolve_requested_asset_version(catalog, Some(text))
255            .map_err(|_| AppError::NotFound("Asset version is not available".into()))?;
256        if pinned.is_some_and(|version| version.version_id != selected.version_id) {
257            return Err(AppError::Conflict("Version selectors disagree".into()));
258        }
259        Ok(selected)
260    }
261
262    /// One orchestration owner for both remote and local adapters.
263    pub async fn list_catalogue_datasets(
264        &self,
265        study: ahri_tre_protocol::study::StudySelector,
266        include_versions: bool,
267    ) -> Result<(StudyRecord, crate::StudyDatasetList), AppError> {
268        let study = self
269            .resolve_study_selector(self.catalogue_study_selector(study)?, "Dataset Study")
270            .await?;
271        let listing = self
272            .list_study_datasets(ListStudyDatasetsRequest {
273                study_id: study.study_id,
274                include_versions,
275            })
276            .await?;
277        Ok((study, listing))
278    }
279    pub async fn list_catalogue_assets(
280        &self,
281        selector: ahri_tre_protocol::study::StudySelector,
282        asset_type: Option<ahri_tre_types::AssetType>,
283    ) -> Result<(StudyRecord, Vec<ahri_tre_protocol::asset::SafeAssetSummary>), AppError> {
284        let datastore_id = self.catalogue_datastore_id()?;
285        let study = self
286            .resolve_study_selector(self.catalogue_study_selector(selector)?, "Asset Study")
287            .await?;
288        let assets = self
289            .list_study_assets(ListStudyAssetsRequest {
290                study_id: study.study_id,
291            })
292            .await?;
293        let mut result = Vec::new();
294        for asset in assets
295            .into_iter()
296            .filter(|asset| asset_type.is_none_or(|kind| kind == asset.asset_type))
297        {
298            result.push(
299                crate::projections::safe_asset_catalog(
300                    datastore_id,
301                    AssetVersionCatalog {
302                        asset,
303                        versions: vec![],
304                        latest_version: None,
305                    },
306                )
307                .asset,
308            );
309        }
310        Ok((study, result))
311    }
312
313    pub async fn read_catalogue_datafile(
314        &self,
315        selector: ahri_tre_protocol::datafile::DataFileSelector,
316        version: Option<&str>,
317    ) -> Result<(StudyRecord, DataFileMetadata), AppError> {
318        use ahri_tre_protocol::{asset::AssetSelector, datafile::DataFileSelector};
319        let selector = match selector {
320            DataFileSelector::Id { datafile, study } => AssetSelector::Id {
321                asset: datafile,
322                study,
323                asset_type: Some(ahri_tre_types::AssetType::File),
324            },
325            DataFileSelector::Name { study, name } => AssetSelector::Name {
326                study,
327                name,
328                asset_type: Some(ahri_tre_types::AssetType::File),
329            },
330        };
331        let (study, catalog, pinned) = self.resolve_catalogue_asset(selector).await?;
332        let selected = self.catalogue_version(&catalog, pinned, version)?;
333        let datafile = self
334            .assets
335            .get_datafile(selected.version_id)
336            .await
337            .map_err(|error| core_error("read Datafile metadata", error))?
338            .ok_or_else(|| AppError::NotFound("Datafile version was not found".into()))?;
339        Ok((
340            study,
341            DataFileMetadata {
342                catalog,
343                version: selected,
344                datafile,
345            },
346        ))
347    }
348
349    pub async fn read_catalogue_dataset(
350        &self,
351        selector: ahri_tre_protocol::dataset::DatasetSelector,
352        with_variables: bool,
353    ) -> Result<DatasetMetadataResult, AppError> {
354        use ahri_tre_protocol::{asset::AssetSelector, dataset::DatasetSelector};
355        let selector = match selector {
356            DatasetSelector::Id { dataset, study } => AssetSelector::Id {
357                asset: dataset,
358                study,
359                asset_type: Some(ahri_tre_types::AssetType::Dataset),
360            },
361            DatasetSelector::Name { study, name } => AssetSelector::Name {
362                study,
363                name,
364                asset_type: Some(ahri_tre_types::AssetType::Dataset),
365            },
366        };
367        let (study, catalog, pinned) = self.resolve_catalogue_asset(selector).await?;
368        let catalog = self.complete_dataset_catalog(catalog.asset).await?;
369        let selected = self.catalogue_version(&catalog, pinned, None)?;
370        let dataset = self
371            .assets
372            .get_dataset(selected.version_id)
373            .await
374            .map_err(|error| core_error("read Dataset metadata", error))?
375            .ok_or_else(|| AppError::NotFound("Dataset version was not found".into()))?;
376        let links = self
377            .variables
378            .list_dataset_variables(dataset.dataset_id)
379            .await
380            .map_err(|error| core_error("list Dataset variables", error))?;
381        let variable_count = links.len();
382        let mut variables = Vec::new();
383        if with_variables {
384            for link in links {
385                variables.push(self.dataset_variable_metadata(link).await?);
386            }
387        }
388        Ok(DatasetMetadataResult {
389            study,
390            metadata: StudyDatasetMetadata {
391                catalog,
392                dataset,
393                variables_included: with_variables,
394                variable_count,
395                variables,
396            },
397        })
398    }
399}