1use crate::{
7 BrowserDictionaryVariableCatalogueRecord, BrowserDictionaryVariableDiscoverySourceRecord,
8 browser_dictionary_variable_search_response,
9};
10use ahri_tre_protocol::{
11 ProtocolVersion, PublicUuid,
12 dictionary::{BrowserVariableSearchResponse, SearchVariablesRequest},
13 dictionary_discovery::{
14 BrowserDictionaryDiscoveryDatasetRef, BrowserDictionaryDiscoveryEmptyState,
15 BrowserDictionaryDiscoveryResourceRef, BrowserDictionaryDiscoveryResponse,
16 },
17 domain::DomainSummary,
18 refs::{DatasetRef, StudyRef},
19 study::BrowserContentSummaryPolicy,
20};
21use ahri_tre_types::VersionId;
22use std::fmt;
23use thiserror::Error;
24
25pub const BROWSER_DICTIONARY_DISCOVERY_AUTHORITY: &str = "tre_browser.dictionary_discovery";
27
28#[derive(Debug, Error, Clone, PartialEq, Eq)]
30pub enum BrowserDictionaryDiscoveryError {
31 #[error("dictionary resource reference is invalid")]
33 InvalidReference(Box<ahri_tre_protocol::ProtocolError>),
34 #[error("selected datastore identity is invalid")]
36 InvalidDatastoreIdentity,
37 #[error("selected datastore is not available for browser discovery")]
39 DatastoreUnavailable,
40 #[error("browser dictionary discovery authority is unavailable")]
42 AuthorityUnavailable,
43 #[error("study was not found in the selected datastore")]
45 StudyNotFound,
46 #[error("resource was not found in the selected study")]
48 ResourceNotFound,
49 #[error("data dictionaries belong to dataset versions")]
51 DataFileDictionaryUnsupported,
52}
53
54#[derive(Debug, Error, Clone, PartialEq, Eq)]
56pub enum BrowserVariableWorkflowError {
57 #[error(transparent)]
59 Discovery(#[from] BrowserDictionaryDiscoveryError),
60 #[error("browser Variable search request is invalid")]
62 InvalidRequest(ahri_tre_protocol::ProtocolError),
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum BrowserDictionaryDiscoveryResourceTarget {
68 Dataset { dataset_id: VersionId },
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct BrowserDictionaryDiscoverySource {
75 pub datastore_id: PublicUuid,
77 pub study: ahri_tre_pgmeta::PgStudyReadModel,
79 pub resource: BrowserDictionaryDiscoveryResourceTarget,
81 pub domains: Vec<ahri_tre_pgmeta::PgDomainReadModel>,
83 pub variables: Vec<BrowserDictionaryVariableDiscoverySourceRecord>,
85}
86
87pub trait BrowserDictionaryDiscoveryCapability: fmt::Debug + Send + Sync {
92 fn discover_dictionary(
97 &self,
98 datastore_id: PublicUuid,
99 study_id: PublicUuid,
100 resource: BrowserDictionaryDiscoveryResourceRef,
101 ) -> Result<BrowserDictionaryDiscoverySource, BrowserDictionaryDiscoveryError>;
102
103 fn discover_catalogue_variables(
107 &self,
108 datastore_id: PublicUuid,
109 ) -> Result<Vec<BrowserDictionaryVariableCatalogueRecord>, BrowserDictionaryDiscoveryError>;
110}
111
112pub fn discover_browser_dictionary(
117 capability: &dyn BrowserDictionaryDiscoveryCapability,
118 datastore_id: PublicUuid,
119 study_id: PublicUuid,
120 resource: BrowserDictionaryDiscoveryResourceRef,
121) -> Result<BrowserDictionaryDiscoveryResponse, BrowserDictionaryDiscoveryError> {
122 if matches!(
123 resource,
124 BrowserDictionaryDiscoveryResourceRef::DataFile { .. }
125 ) {
126 return Err(BrowserDictionaryDiscoveryError::DataFileDictionaryUnsupported);
127 }
128 if let BrowserDictionaryDiscoveryResourceRef::Dataset { dataset } = &resource {
129 dataset
130 .validate_context(
131 datastore_id,
132 ahri_tre_protocol::refs::ObjectKind::AssetVersion,
133 )
134 .map_err(|error| BrowserDictionaryDiscoveryError::InvalidReference(Box::new(error)))?;
135 }
136 capability
137 .discover_dictionary(datastore_id, study_id, resource)
138 .map(browser_dictionary_discovery_response)
139}
140
141#[allow(clippy::result_large_err)]
142pub fn search_browser_variables(
148 capability: &dyn BrowserDictionaryDiscoveryCapability,
149 datastore_id: PublicUuid,
150 request: &SearchVariablesRequest,
151 protocol_version: &ProtocolVersion,
152) -> Result<BrowserVariableSearchResponse, BrowserVariableWorkflowError> {
153 let sources = capability.discover_catalogue_variables(datastore_id)?;
154 browser_dictionary_variable_search_response(datastore_id, sources, request, protocol_version)
155 .map_err(BrowserVariableWorkflowError::InvalidRequest)
156}
157
158pub fn browser_dictionary_discovery_response(
163 source: BrowserDictionaryDiscoverySource,
164) -> BrowserDictionaryDiscoveryResponse {
165 let datastore_id = source.datastore_id;
166 let variables = source
167 .variables
168 .into_iter()
169 .map(|value| crate::variable_record(datastore_id, value))
170 .collect::<Vec<_>>();
171 BrowserDictionaryDiscoveryResponse {
172 authority: BROWSER_DICTIONARY_DISCOVERY_AUTHORITY.to_string(),
173 datastore_id,
174 study: StudyRef {
175 datastore_id,
176 kind: ahri_tre_protocol::refs::ObjectKind::Study,
177 id: PublicUuid::from_uuid(source.study.study_id.0),
178 },
179 resource: resource_ref(datastore_id, source.resource),
180 domains: source
181 .domains
182 .into_iter()
183 .map(|value| domain_summary(datastore_id, value))
184 .collect(),
185 empty_state: variables
186 .is_empty()
187 .then(|| BrowserDictionaryDiscoveryEmptyState {
188 code: "no_browser_visible_dictionary_variables".to_string(),
189 message: "No browser-visible dictionary variables are available for this resource."
190 .to_string(),
191 }),
192 variables,
193 content_derived_summaries: BrowserContentSummaryPolicy {
194 included: false,
195 reason:
196 "Content-derived summaries are excluded from data dictionary Discovery metadata."
197 .to_string(),
198 },
199 }
200}
201
202fn resource_ref(
203 datastore_id: PublicUuid,
204 resource: BrowserDictionaryDiscoveryResourceTarget,
205) -> BrowserDictionaryDiscoveryDatasetRef {
206 match resource {
207 BrowserDictionaryDiscoveryResourceTarget::Dataset { dataset_id } => {
208 BrowserDictionaryDiscoveryDatasetRef::Dataset {
209 dataset: DatasetRef {
210 datastore_id,
211 kind: ahri_tre_protocol::refs::ObjectKind::AssetVersion,
212 id: PublicUuid::from_uuid(dataset_id.0),
213 },
214 }
215 }
216 }
217}
218
219fn domain_summary(
220 datastore_id: PublicUuid,
221 domain: ahri_tre_pgmeta::PgDomainReadModel,
222) -> DomainSummary {
223 DomainSummary {
224 domain: crate::projections::domain_ref(datastore_id, domain.domain_id),
225 name: domain.name,
226 description: domain.description,
227 uri: domain.uri,
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use ahri_tre_types::StudyId;
235 use uuid::Uuid;
236
237 #[derive(Debug)]
238 struct NoReads;
239 impl BrowserDictionaryDiscoveryCapability for NoReads {
240 fn discover_dictionary(
241 &self,
242 _: PublicUuid,
243 _: PublicUuid,
244 _: BrowserDictionaryDiscoveryResourceRef,
245 ) -> Result<BrowserDictionaryDiscoverySource, BrowserDictionaryDiscoveryError> {
246 panic!("invalid resource reference must fail before lookup")
247 }
248 fn discover_catalogue_variables(
249 &self,
250 _: PublicUuid,
251 ) -> Result<Vec<BrowserDictionaryVariableCatalogueRecord>, BrowserDictionaryDiscoveryError>
252 {
253 panic!("invalid resource reference must fail before lookup")
254 }
255 }
256
257 #[test]
258 fn dictionary_rejects_foreign_and_wrong_kind_revisions_before_lookup() {
259 use ahri_tre_protocol::{
260 ProtocolErrorCode,
261 refs::{ObjectKind, ObjectRef},
262 };
263 let datastore = PublicUuid::from_uuid(Uuid::from_u128(1));
264 let study = PublicUuid::from_uuid(Uuid::from_u128(2));
265 for (origin, kind, code) in [
266 (
267 PublicUuid::from_uuid(Uuid::from_u128(9)),
268 ObjectKind::AssetVersion,
269 ProtocolErrorCode::Conflict,
270 ),
271 (
272 datastore,
273 ObjectKind::Asset,
274 ProtocolErrorCode::ValidationFailed,
275 ),
276 ] {
277 let resource = BrowserDictionaryDiscoveryResourceRef::Dataset {
278 dataset: ObjectRef {
279 datastore_id: origin,
280 kind,
281 id: PublicUuid::from_uuid(Uuid::from_u128(3)),
282 },
283 };
284 assert!(
285 matches!(discover_browser_dictionary(&NoReads, datastore, study, resource),
286 Err(BrowserDictionaryDiscoveryError::InvalidReference(error)) if error.code == code)
287 );
288 }
289 }
290
291 #[test]
292 fn empty_dictionary_projection_is_metadata_only_and_stable() {
293 let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(1));
294 let response = browser_dictionary_discovery_response(BrowserDictionaryDiscoverySource {
295 datastore_id,
296 study: ahri_tre_pgmeta::PgStudyReadModel {
297 study_id: StudyId(Uuid::from_u128(2)),
298 name: "cohort".to_string(),
299 description: None,
300 documentation: None,
301 agent_instructions: Some("private instructions".to_string()),
302 external_id: None,
303 study_type_id: None,
304 date_created: None,
305 created_by: Some("private actor".to_string()),
306 },
307 resource: BrowserDictionaryDiscoveryResourceTarget::Dataset {
308 dataset_id: VersionId(Uuid::from_u128(3)),
309 },
310 domains: Vec::new(),
311 variables: Vec::new(),
312 });
313
314 assert_eq!(response.authority, BROWSER_DICTIONARY_DISCOVERY_AUTHORITY);
315 assert_eq!(
316 response.empty_state.as_ref().unwrap().code,
317 "no_browser_visible_dictionary_variables"
318 );
319 assert!(!response.content_derived_summaries.included);
320 let serialized = serde_json::to_string(&response).unwrap();
321 assert!(!serialized.contains("private instructions"));
322 assert!(!serialized.contains("private actor"));
323 }
324}