1use crate::browser_study_discovery::{BrowserStudyDiscoverySource, browser_study_facts};
2use crate::cursor_integrity::hmac_sha256;
3use ahri_tre_protocol::{
4 ProtocolVersion, PublicUuid,
5 domain::{
6 BrowserDomainCollectionFilters, BrowserDomainCollectionRequest,
7 BrowserDomainCollectionResponse, BrowserDomainDetailRequest, BrowserDomainDetailResponse,
8 BrowserDomainEmptyState, BrowserDomainPageMetadata, BrowserDomainRecord,
9 BrowserDomainStudyCollectionRequest, BrowserDomainStudyCollectionResponse,
10 BrowserDomainTextFilter, BrowserDomainVariableCollectionRequest,
11 BrowserDomainVariableCollectionResponse, BrowserDomainVariableDetail,
12 BrowserDomainVariableDetailRequest, BrowserDomainVariableDetailResponse,
13 BrowserDomainVariableRecord, BrowserDomainVariableVocabulary, BrowserVocabularyDetail,
14 BrowserVocabularyDetailRequest, BrowserVocabularyDetailResponse,
15 BrowserVocabularyItemCollectionRequest, BrowserVocabularyItemCollectionResponse,
16 BrowserVocabularyItemRecord, BrowserVocabularyMappingCollectionRequest,
17 BrowserVocabularyMappingCollectionResponse, BrowserVocabularyMappingEndpoint,
18 BrowserVocabularyMappingRecord,
19 },
20 refs::{DomainRef, TagRef, TagScope, TagSummary, VariableRef, VocabularyRef},
21};
22use ahri_tre_types::{DomainId, VariableId, VocabularyId};
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25use std::fmt;
26use std::sync::OnceLock;
27use thiserror::Error;
28use uuid::Uuid;
29
30const DEFAULT_LIMIT: u16 = 100;
31const MAX_LIMIT: u16 = 500;
32const COLLECTION: &str = "domains";
33const ORDERING: &str = "name_identity_v1";
34const ORDERING_DESCRIPTION: &str =
35 "Case-insensitive Domain name ascending, then stable Domain identity ascending.";
36const STUDY_ORDERING_DESCRIPTION: &str =
37 "Case-insensitive Study name ascending, then stable Study identity ascending.";
38static CURSOR_MAC_KEY: OnceLock<[u8; 16]> = OnceLock::new();
39const DOMAIN_STUDIES_COLLECTION: &str = "domain_studies";
40const DOMAIN_VARIABLES_COLLECTION: &str = "domain_variables";
41const VARIABLE_ORDERING_DESCRIPTION: &str =
42 "Case-insensitive Variable name ascending, then stable Variable identity ascending.";
43const VOCABULARY_ITEMS_COLLECTION: &str = "vocabulary_items";
44const VOCABULARY_ITEM_ORDERING: &str = "code_identity_v1";
45const VOCABULARY_ITEM_ORDERING_DESCRIPTION: &str =
46 "Public integer code ascending, then stable Vocabulary item identity ascending.";
47const VOCABULARY_MAPPINGS_COLLECTION: &str = "vocabulary_mappings";
48const VOCABULARY_MAPPING_ORDERING: &str = "source_target_identity_v1";
49const VOCABULARY_MAPPING_ORDERING_DESCRIPTION: &str = "Source Vocabulary identity, source item code and identity, target Vocabulary identity, target item code and identity, then stable mapping identity ascending.";
50
51#[derive(Debug, Error, Clone, PartialEq, Eq)]
52pub enum BrowserDomainDiscoveryError {
53 #[error("invalid catalogue reference")]
54 InvalidReference(Box<ahri_tre_protocol::ProtocolError>),
55 #[error("selected datastore identity is invalid")]
56 InvalidDatastoreIdentity,
57 #[error("selected datastore is unavailable")]
58 DatastoreUnavailable,
59 #[error("selected datastore is unavailable for associated Study discovery")]
60 StudyDatastoreUnavailable,
61 #[error("browser Domain discovery authority is unavailable")]
62 AuthorityUnavailable,
63 #[error("Domain was not found")]
64 DomainNotFound,
65 #[error("Variable was not found")]
66 VariableNotFound,
67 #[error("Vocabulary was not found")]
68 VocabularyNotFound,
69 #[error("page request is invalid")]
70 InvalidPage,
71 #[error("Domain collection filters are invalid")]
72 InvalidFilters,
73 #[error("cursor is invalid or does not match this collection")]
74 InvalidCursor,
75 #[error("cursor is stale")]
76 StaleCursor,
77 #[error("protocol version is unsupported")]
78 UnsupportedVersion,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct BrowserDomainSourceRecord {
83 pub domain: ahri_tre_pgmeta::PgDomainReadModel,
84 pub tags: Vec<ahri_tre_pgmeta::PgTagReadModel>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct BrowserDomainVariableSourceRecord {
89 pub variable: ahri_tre_pgmeta::PgVariableReadModel,
90 pub vocabulary: Option<ahri_tre_pgmeta::PgVocabularyReadModel>,
91 pub tags: Vec<ahri_tre_pgmeta::PgTagReadModel>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct BrowserVocabularySource {
96 pub vocabulary: ahri_tre_pgmeta::PgVocabularyReadModel,
97 pub items: Vec<ahri_tre_pgmeta::PgVocabularyItemReadModel>,
98 pub mappings: Vec<BrowserVocabularyMappingSourceRecord>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct BrowserVocabularyMappingSourceRecord {
103 pub mapping: ahri_tre_pgmeta::PgVocabularyMappingReadModel,
104 pub from_item: ahri_tre_pgmeta::PgVocabularyItemReadModel,
105 pub from_domain_id: DomainId,
106 pub to_item: ahri_tre_pgmeta::PgVocabularyItemReadModel,
107 pub to_domain_id: DomainId,
108}
109
110pub trait BrowserDomainDiscoveryCapability: fmt::Debug + Send + Sync {
112 fn discover_domains(
113 &self,
114 datastore_id: PublicUuid,
115 ) -> Result<Vec<BrowserDomainSourceRecord>, BrowserDomainDiscoveryError>;
116
117 fn discover_domain_studies(
118 &self,
119 datastore_id: PublicUuid,
120 domain_id: DomainId,
121 ) -> Result<BrowserStudyDiscoverySource, BrowserDomainDiscoveryError>;
122
123 fn discover_domain_variables(
124 &self,
125 datastore_id: PublicUuid,
126 domain_id: DomainId,
127 ) -> Result<Vec<BrowserDomainVariableSourceRecord>, BrowserDomainDiscoveryError>;
128
129 fn discover_vocabulary(
130 &self,
131 datastore_id: PublicUuid,
132 vocabulary_id: VocabularyId,
133 include_items: bool,
134 include_mappings: bool,
135 ) -> Result<Option<BrowserVocabularySource>, BrowserDomainDiscoveryError>;
136}
137
138pub fn list_browser_domains(
139 capability: &dyn BrowserDomainDiscoveryCapability,
140 request: &BrowserDomainCollectionRequest,
141) -> Result<BrowserDomainCollectionResponse, BrowserDomainDiscoveryError> {
142 for tag in &request.filters.any_tags {
143 crate::browser_catalogue_search::validate_tag(request.datastore_id, tag)
144 .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
145 }
146 collection_response(
147 capability.discover_domains(request.datastore_id)?,
148 request.datastore_id,
149 request.filters.clone(),
150 Some(request.page.limit),
151 request.page.cursor.as_deref(),
152 request.requested_protocol_version.as_deref(),
153 )
154}
155
156pub fn get_browser_domain(
157 capability: &dyn BrowserDomainDiscoveryCapability,
158 request: &BrowserDomainDetailRequest,
159) -> Result<BrowserDomainDetailResponse, BrowserDomainDiscoveryError> {
160 request
161 .domain
162 .validate_context(
163 request.datastore_id,
164 ahri_tre_protocol::refs::ObjectKind::Domain,
165 )
166 .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
167 detail_response(
168 capability.discover_domains(request.datastore_id)?,
169 request.datastore_id,
170 request.domain.id,
171 request.requested_protocol_version.as_deref(),
172 )
173}
174
175pub fn list_browser_domain_studies(
176 capability: &dyn BrowserDomainDiscoveryCapability,
177 request: &BrowserDomainStudyCollectionRequest,
178) -> Result<BrowserDomainStudyCollectionResponse, BrowserDomainDiscoveryError> {
179 request
180 .domain
181 .validate_context(
182 request.datastore_id,
183 ahri_tre_protocol::refs::ObjectKind::Domain,
184 )
185 .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
186 get_browser_domain(
187 capability,
188 &BrowserDomainDetailRequest {
189 datastore_id: request.datastore_id,
190 domain: request.domain,
191 requested_protocol_version: request.requested_protocol_version.clone(),
192 },
193 )?;
194 let domain_id =
195 internal_domain_id(request.domain.id).ok_or(BrowserDomainDiscoveryError::DomainNotFound)?;
196 study_collection_response(
197 capability.discover_domain_studies(request.datastore_id, domain_id)?,
198 request.domain.id,
199 Some(request.page.limit),
200 request.page.cursor.as_deref(),
201 request.requested_protocol_version.as_deref(),
202 )
203}
204
205pub fn list_browser_domain_variables(
206 capability: &dyn BrowserDomainDiscoveryCapability,
207 request: &BrowserDomainVariableCollectionRequest,
208) -> Result<BrowserDomainVariableCollectionResponse, BrowserDomainDiscoveryError> {
209 request
210 .domain
211 .validate_context(
212 request.datastore_id,
213 ahri_tre_protocol::refs::ObjectKind::Domain,
214 )
215 .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
216 ensure_browser_domain(capability, request.datastore_id, request.domain.id)?;
217 let domain_id =
218 internal_domain_id(request.domain.id).ok_or(BrowserDomainDiscoveryError::DomainNotFound)?;
219 variable_collection_response(
220 capability.discover_domain_variables(request.datastore_id, domain_id)?,
221 request.datastore_id,
222 request.domain.id,
223 Some(request.page.limit),
224 request.page.cursor.as_deref(),
225 None,
226 )
227}
228
229pub fn get_browser_domain_variable(
230 capability: &dyn BrowserDomainDiscoveryCapability,
231 request: &BrowserDomainVariableDetailRequest,
232) -> Result<BrowserDomainVariableDetailResponse, BrowserDomainDiscoveryError> {
233 request
234 .variable
235 .validate_context(
236 request.datastore_id,
237 ahri_tre_protocol::refs::ObjectKind::Variable,
238 )
239 .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
240
241 request
242 .domain
243 .validate_context(
244 request.datastore_id,
245 ahri_tre_protocol::refs::ObjectKind::Domain,
246 )
247 .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
248 ensure_browser_domain(capability, request.datastore_id, request.domain.id)?;
249 let domain_id =
250 internal_domain_id(request.domain.id).ok_or(BrowserDomainDiscoveryError::DomainNotFound)?;
251 variable_detail_response(
252 capability.discover_domain_variables(request.datastore_id, domain_id)?,
253 request.datastore_id,
254 request.domain.id,
255 request.variable.id,
256 None,
257 )
258}
259
260pub fn get_browser_vocabulary(
261 capability: &dyn BrowserDomainDiscoveryCapability,
262 request: &BrowserVocabularyDetailRequest,
263) -> Result<BrowserVocabularyDetailResponse, BrowserDomainDiscoveryError> {
264 request
265 .vocabulary
266 .validate_context(
267 request.datastore_id,
268 ahri_tre_protocol::refs::ObjectKind::Vocabulary,
269 )
270 .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
271
272 request
273 .domain
274 .validate_context(
275 request.datastore_id,
276 ahri_tre_protocol::refs::ObjectKind::Domain,
277 )
278 .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
279 let source = discover_browser_vocabulary(
280 capability,
281 request.datastore_id,
282 request.domain.id,
283 request.vocabulary.id,
284 false,
285 false,
286 )?;
287 vocabulary_detail_response(
288 source,
289 request.datastore_id,
290 request.domain.id,
291 request.vocabulary.id,
292 )
293}
294
295pub fn list_browser_vocabulary_items(
296 capability: &dyn BrowserDomainDiscoveryCapability,
297 request: &BrowserVocabularyItemCollectionRequest,
298) -> Result<BrowserVocabularyItemCollectionResponse, BrowserDomainDiscoveryError> {
299 request
300 .vocabulary
301 .validate_context(
302 request.datastore_id,
303 ahri_tre_protocol::refs::ObjectKind::Vocabulary,
304 )
305 .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
306
307 request
308 .domain
309 .validate_context(
310 request.datastore_id,
311 ahri_tre_protocol::refs::ObjectKind::Domain,
312 )
313 .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
314 let source = discover_browser_vocabulary(
315 capability,
316 request.datastore_id,
317 request.domain.id,
318 request.vocabulary.id,
319 true,
320 false,
321 )?;
322 vocabulary_item_collection_response(
323 source,
324 request.datastore_id,
325 request.domain.id,
326 request.vocabulary.id,
327 Some(request.page.limit),
328 request.page.cursor.as_deref(),
329 )
330}
331
332pub fn list_browser_vocabulary_mappings(
333 capability: &dyn BrowserDomainDiscoveryCapability,
334 request: &BrowserVocabularyMappingCollectionRequest,
335) -> Result<BrowserVocabularyMappingCollectionResponse, BrowserDomainDiscoveryError> {
336 request
337 .vocabulary
338 .validate_context(
339 request.datastore_id,
340 ahri_tre_protocol::refs::ObjectKind::Vocabulary,
341 )
342 .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
343
344 request
345 .domain
346 .validate_context(
347 request.datastore_id,
348 ahri_tre_protocol::refs::ObjectKind::Domain,
349 )
350 .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
351 let source = discover_browser_vocabulary(
352 capability,
353 request.datastore_id,
354 request.domain.id,
355 request.vocabulary.id,
356 false,
357 true,
358 )?;
359 vocabulary_mapping_collection_response(
360 source,
361 request.datastore_id,
362 request.domain.id,
363 request.vocabulary.id,
364 Some(request.page.limit),
365 request.page.cursor.as_deref(),
366 )
367}
368
369fn ensure_browser_domain(
370 capability: &dyn BrowserDomainDiscoveryCapability,
371 datastore_id: PublicUuid,
372 domain_id: PublicUuid,
373) -> Result<(), BrowserDomainDiscoveryError> {
374 detail_response(
375 capability.discover_domains(datastore_id)?,
376 datastore_id,
377 domain_id,
378 None,
379 )?;
380 Ok(())
381}
382
383fn discover_browser_vocabulary(
384 capability: &dyn BrowserDomainDiscoveryCapability,
385 datastore_id: PublicUuid,
386 domain_id: PublicUuid,
387 vocabulary_id: PublicUuid,
388 include_items: bool,
389 include_mappings: bool,
390) -> Result<BrowserVocabularySource, BrowserDomainDiscoveryError> {
391 ensure_browser_domain(capability, datastore_id, domain_id)?;
392 let internal = internal_vocabulary_id(vocabulary_id)
393 .ok_or(BrowserDomainDiscoveryError::VocabularyNotFound)?;
394 capability
395 .discover_vocabulary(datastore_id, internal, include_items, include_mappings)?
396 .ok_or(BrowserDomainDiscoveryError::VocabularyNotFound)
397}
398
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(rename_all = "snake_case")]
401enum Direction {
402 Forward,
403 Backward,
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
407struct Cursor {
408 datastore_id: PublicUuid,
409 collection: String,
410 limit: u16,
411 ordering: String,
412 direction: Direction,
413 boundary: usize,
414 protocol_version: String,
415 snapshot: String,
416 #[serde(default)]
417 filters: BrowserDomainCollectionFilters,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
419 domain_id: Option<PublicUuid>,
420 #[serde(default, skip_serializing_if = "Option::is_none")]
421 vocabulary_id: Option<PublicUuid>,
422 integrity: String,
423}
424
425pub fn collection_response(
426 mut source: Vec<BrowserDomainSourceRecord>,
427 datastore_id: PublicUuid,
428 mut filters: BrowserDomainCollectionFilters,
429 limit: Option<u16>,
430 raw_cursor: Option<&str>,
431 requested_version: Option<&str>,
432) -> Result<BrowserDomainCollectionResponse, BrowserDomainDiscoveryError> {
433 for tag in &filters.any_tags {
434 crate::browser_catalogue_search::validate_tag(datastore_id, tag)
435 .map_err(|error| BrowserDomainDiscoveryError::InvalidReference(Box::new(error)))?;
436 }
437 let mut validation_request = BrowserDomainCollectionRequest {
438 datastore_id,
439 filters,
440 page: ahri_tre_protocol::pagination::PageRequest::default(),
441 requested_protocol_version: requested_version.map(str::to_owned),
442 };
443 validation_request
444 .normalize_and_validate()
445 .map_err(|_| BrowserDomainDiscoveryError::InvalidFilters)?;
446 filters = validation_request.filters;
447 let version = requested_version
448 .map(ProtocolVersion::parse)
449 .transpose()
450 .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?
451 .unwrap_or_default();
452 version
453 .ensure_supported(&ProtocolVersion::current())
454 .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?;
455 let limit = limit.unwrap_or(DEFAULT_LIMIT);
456 if limit == 0 || limit > MAX_LIMIT {
457 return Err(BrowserDomainDiscoveryError::InvalidPage);
458 }
459 source.retain(|record| domain_matches_filters(record, &filters));
460 source.sort_by(|a, b| {
461 a.domain
462 .name
463 .to_lowercase()
464 .cmp(&b.domain.name.to_lowercase())
465 .then(a.domain.domain_id.0.cmp(&b.domain.domain_id.0))
466 });
467 let snapshot = snapshot(&source);
468 let (start, direction) = match raw_cursor {
469 None => (0, Direction::Forward),
470 Some(raw) => {
471 let cursor = decode_cursor(raw)?;
472 if cursor.datastore_id != datastore_id
473 || cursor.collection != COLLECTION
474 || cursor.limit != limit
475 || cursor.ordering != ORDERING
476 || cursor.protocol_version != version.to_string()
477 || cursor.filters != filters
478 || cursor.domain_id.is_some()
479 || cursor.integrity != integrity(&cursor)
480 {
481 return Err(BrowserDomainDiscoveryError::InvalidCursor);
482 }
483 if cursor.snapshot != snapshot {
484 return Err(BrowserDomainDiscoveryError::StaleCursor);
485 }
486 if cursor.boundary == 0 || cursor.boundary >= source.len() {
487 return Err(BrowserDomainDiscoveryError::InvalidCursor);
488 }
489 (cursor.boundary, cursor.direction)
490 }
491 };
492 let start = match direction {
493 Direction::Forward => start.min(source.len()),
494 Direction::Backward => start.saturating_sub(limit as usize),
495 };
496 let end = (start + limit as usize).min(source.len());
497 let domains = source[start..end]
498 .iter()
499 .cloned()
500 .map(|value| project(datastore_id, value))
501 .collect::<Vec<_>>();
502 let returned_count = domains.len();
503 let previous_cursor = (start > 0).then(|| {
504 encode_cursor(domain_cursor(
505 datastore_id,
506 limit,
507 Direction::Backward,
508 start,
509 &version,
510 &snapshot,
511 &filters,
512 ))
513 });
514 let next_cursor = (end < source.len()).then(|| {
515 encode_cursor(domain_cursor(
516 datastore_id,
517 limit,
518 Direction::Forward,
519 end,
520 &version,
521 &snapshot,
522 &filters,
523 ))
524 });
525 Ok(BrowserDomainCollectionResponse {
526 protocol_version: version.clone(),
527 datastore_id,
528 filters: filters.clone(),
529 returned_count,
530 has_more: next_cursor.is_some(),
531 empty_state: domains.is_empty().then(|| {
532 if filters == BrowserDomainCollectionFilters::default() {
533 BrowserDomainEmptyState {
534 code: "no_domains".into(),
535 message: "No Domains are available in this datastore.".into(),
536 }
537 } else {
538 BrowserDomainEmptyState {
539 code: "no_matching_browser_visible_domains".into(),
540 message: "No browser-visible Domains match the active filters.".into(),
541 }
542 }
543 }),
544 page: BrowserDomainPageMetadata {
545 limit,
546 returned: returned_count,
547 ordering: ORDERING.into(),
548 ordering_description: ORDERING_DESCRIPTION.into(),
549 previous_cursor,
550 next_cursor,
551 },
552 domains,
553 })
554}
555
556fn domain_matches_filters(
557 record: &BrowserDomainSourceRecord,
558 filters: &BrowserDomainCollectionFilters,
559) -> bool {
560 let text_matches = filters.text.as_ref().is_none_or(|text| {
561 domain_text_values(record).any(|candidate| domain_text_matches(candidate, text))
562 });
563 let tag_matches = filters.any_tags.is_empty()
564 || record.tags.iter().any(|tag| {
565 filters
566 .any_tags
567 .iter()
568 .any(|selector| domain_tag_matches(tag, selector))
569 });
570 text_matches && tag_matches
571}
572
573fn domain_text_values(record: &BrowserDomainSourceRecord) -> impl Iterator<Item = &str> {
574 std::iter::once(record.domain.name.as_str())
575 .chain(record.domain.description.as_deref())
576 .chain(record.domain.uri.as_deref())
577}
578
579fn domain_text_matches(candidate: &str, filter: &BrowserDomainTextFilter) -> bool {
580 let candidate = candidate.to_lowercase();
581 let value = filter.value.to_lowercase();
582 match filter.mode {
583 ahri_tre_protocol::search::TextMode::Exact => candidate == value,
584 ahri_tre_protocol::search::TextMode::Prefix => candidate.starts_with(&value),
585 ahri_tre_protocol::search::TextMode::Contains => candidate.contains(&value),
586 }
587}
588
589fn domain_tag_matches(
590 tag: &ahri_tre_pgmeta::PgTagReadModel,
591 selector: &ahri_tre_protocol::refs::TagSelector,
592) -> bool {
593 match selector {
594 ahri_tre_protocol::refs::TagSelector::Id { tag: expected } => {
595 ahri_tre_protocol::refs::encode_scoped_integer_ref("tag", tag.tag_id) == expected.id
596 }
597 ahri_tre_protocol::refs::TagSelector::Label { scope, label } => {
598 *scope == TagScope::Global && tag.name == label.as_str()
599 }
600 }
601}
602
603pub fn detail_response(
604 source: Vec<BrowserDomainSourceRecord>,
605 datastore_id: PublicUuid,
606 domain_id: PublicUuid,
607 requested_version: Option<&str>,
608) -> Result<BrowserDomainDetailResponse, BrowserDomainDiscoveryError> {
609 let version = requested_version
610 .map(ProtocolVersion::parse)
611 .transpose()
612 .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?
613 .unwrap_or_default();
614 version
615 .ensure_supported(&ProtocolVersion::current())
616 .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?;
617 let record = source
618 .into_iter()
619 .find(|record| public_domain_id(record.domain.domain_id) == domain_id)
620 .ok_or(BrowserDomainDiscoveryError::DomainNotFound)?;
621 Ok(BrowserDomainDetailResponse {
622 protocol_version: version,
623 datastore_id,
624 domain: project(datastore_id, record),
625 })
626}
627
628pub fn study_collection_response(
629 source: BrowserStudyDiscoverySource,
630 domain_id: PublicUuid,
631 limit: Option<u16>,
632 raw_cursor: Option<&str>,
633 requested_version: Option<&str>,
634) -> Result<BrowserDomainStudyCollectionResponse, BrowserDomainDiscoveryError> {
635 let version = requested_version
636 .map(ProtocolVersion::parse)
637 .transpose()
638 .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?
639 .unwrap_or_default();
640 version
641 .ensure_supported(&ProtocolVersion::current())
642 .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?;
643 let limit = limit.unwrap_or(DEFAULT_LIMIT);
644 if limit == 0 || limit > MAX_LIMIT {
645 return Err(BrowserDomainDiscoveryError::InvalidPage);
646 }
647 let datastore_id = source.datastore_id;
648 let mut studies = source
649 .studies
650 .into_iter()
651 .map(|record| browser_study_facts(datastore_id, &record))
652 .collect::<Vec<_>>();
653 studies.sort_by(|a, b| {
654 a.name
655 .to_lowercase()
656 .cmp(&b.name.to_lowercase())
657 .then(a.study.id.to_string().cmp(&b.study.id.to_string()))
658 });
659 let snapshot = study_snapshot(&studies);
660 let (boundary, direction) = match raw_cursor {
661 None => (0, Direction::Forward),
662 Some(raw) => {
663 let cursor = decode_cursor(raw)?;
664 if cursor.datastore_id != datastore_id
665 || cursor.collection != DOMAIN_STUDIES_COLLECTION
666 || cursor.domain_id != Some(domain_id)
667 || cursor.limit != limit
668 || cursor.ordering != ORDERING
669 || cursor.protocol_version != version.to_string()
670 || cursor.integrity != integrity(&cursor)
671 {
672 return Err(BrowserDomainDiscoveryError::InvalidCursor);
673 }
674 if cursor.snapshot != snapshot {
675 return Err(BrowserDomainDiscoveryError::StaleCursor);
676 }
677 if cursor.boundary == 0 || cursor.boundary >= studies.len() {
678 return Err(BrowserDomainDiscoveryError::InvalidCursor);
679 }
680 (cursor.boundary, cursor.direction)
681 }
682 };
683 let start = match direction {
684 Direction::Forward => boundary.min(studies.len()),
685 Direction::Backward => boundary.saturating_sub(limit as usize),
686 };
687 let end = (start + limit as usize).min(studies.len());
688 let page_studies = studies[start..end].to_vec();
689 let make_cursor = |direction, boundary| {
690 let mut value = cursor(
691 datastore_id,
692 limit,
693 direction,
694 boundary,
695 &version,
696 &snapshot,
697 );
698 value.collection = DOMAIN_STUDIES_COLLECTION.into();
699 value.domain_id = Some(domain_id);
700 value.integrity = integrity(&value);
701 encode_cursor(value)
702 };
703 Ok(BrowserDomainStudyCollectionResponse {
704 protocol_version: version.clone(),
705 datastore_id,
706 domain: DomainRef {
707 datastore_id,
708 kind: ahri_tre_protocol::refs::ObjectKind::Domain,
709 id: domain_id,
710 },
711 empty_state: page_studies.is_empty().then(|| BrowserDomainEmptyState {
712 code: "no_domain_studies".into(),
713 message: "No Studies are associated with this Domain.".into(),
714 }),
715 page: BrowserDomainPageMetadata {
716 limit,
717 returned: page_studies.len(),
718 ordering: ORDERING.into(),
719 ordering_description: STUDY_ORDERING_DESCRIPTION.into(),
720 previous_cursor: (start > 0).then(|| make_cursor(Direction::Backward, start)),
721 next_cursor: (end < studies.len()).then(|| make_cursor(Direction::Forward, end)),
722 },
723 studies: page_studies,
724 })
725}
726
727pub fn variable_collection_response(
728 mut source: Vec<BrowserDomainVariableSourceRecord>,
729 datastore_id: PublicUuid,
730 domain_id: PublicUuid,
731 limit: Option<u16>,
732 raw_cursor: Option<&str>,
733 requested_version: Option<&str>,
734) -> Result<BrowserDomainVariableCollectionResponse, BrowserDomainDiscoveryError> {
735 let version = requested_version
736 .map(ProtocolVersion::parse)
737 .transpose()
738 .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?
739 .unwrap_or_default();
740 version
741 .ensure_supported(&ProtocolVersion::current())
742 .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?;
743 let limit = limit.unwrap_or(DEFAULT_LIMIT);
744 if limit == 0 || limit > MAX_LIMIT {
745 return Err(BrowserDomainDiscoveryError::InvalidPage);
746 }
747 source.sort_by(|a, b| {
748 a.variable
749 .name
750 .to_lowercase()
751 .cmp(&b.variable.name.to_lowercase())
752 .then(a.variable.variable_id.0.cmp(&b.variable.variable_id.0))
753 });
754 let projected = source
755 .iter()
756 .map(|source| variable_record(datastore_id, source))
757 .collect::<Vec<_>>();
758 let snapshot = digest(&projected);
759 let (boundary, direction) = match raw_cursor {
760 None => (0, Direction::Forward),
761 Some(raw) => {
762 let cursor = decode_cursor(raw)?;
763 if cursor.datastore_id != datastore_id
764 || cursor.collection != DOMAIN_VARIABLES_COLLECTION
765 || cursor.domain_id != Some(domain_id)
766 || cursor.limit != limit
767 || cursor.ordering != ORDERING
768 || cursor.protocol_version != version.to_string()
769 || cursor.integrity != integrity(&cursor)
770 {
771 return Err(BrowserDomainDiscoveryError::InvalidCursor);
772 }
773 if cursor.snapshot != snapshot {
774 return Err(BrowserDomainDiscoveryError::StaleCursor);
775 }
776 if cursor.boundary == 0 || cursor.boundary >= projected.len() {
777 return Err(BrowserDomainDiscoveryError::InvalidCursor);
778 }
779 (cursor.boundary, cursor.direction)
780 }
781 };
782 let start = match direction {
783 Direction::Forward => boundary.min(projected.len()),
784 Direction::Backward => boundary.saturating_sub(limit as usize),
785 };
786 let end = (start + limit as usize).min(projected.len());
787 let make_cursor = |direction, boundary| {
788 let mut value = cursor(
789 datastore_id,
790 limit,
791 direction,
792 boundary,
793 &version,
794 &snapshot,
795 );
796 value.collection = DOMAIN_VARIABLES_COLLECTION.into();
797 value.domain_id = Some(domain_id);
798 value.integrity = integrity(&value);
799 encode_cursor(value)
800 };
801 let variables = projected[start..end].to_vec();
802 Ok(BrowserDomainVariableCollectionResponse {
803 protocol_version: version.clone(),
804 datastore_id,
805 domain: DomainRef {
806 datastore_id,
807 kind: ahri_tre_protocol::refs::ObjectKind::Domain,
808 id: domain_id,
809 },
810 empty_state: variables.is_empty().then(|| BrowserDomainEmptyState {
811 code: "no_domain_variables".into(),
812 message: "No Variables belong to this Domain.".into(),
813 }),
814 page: BrowserDomainPageMetadata {
815 limit,
816 returned: variables.len(),
817 ordering: ORDERING.into(),
818 ordering_description: VARIABLE_ORDERING_DESCRIPTION.into(),
819 previous_cursor: (start > 0).then(|| make_cursor(Direction::Backward, start)),
820 next_cursor: (end < projected.len()).then(|| make_cursor(Direction::Forward, end)),
821 },
822 variables,
823 })
824}
825
826pub fn variable_detail_response(
827 source: Vec<BrowserDomainVariableSourceRecord>,
828 datastore_id: PublicUuid,
829 domain_id: PublicUuid,
830 variable_id: PublicUuid,
831 requested_version: Option<&str>,
832) -> Result<BrowserDomainVariableDetailResponse, BrowserDomainDiscoveryError> {
833 let version = requested_version
834 .map(ProtocolVersion::parse)
835 .transpose()
836 .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?
837 .unwrap_or_default();
838 version
839 .ensure_supported(&ProtocolVersion::current())
840 .map_err(|_| BrowserDomainDiscoveryError::UnsupportedVersion)?;
841 let source = source
842 .into_iter()
843 .find(|record| public_variable_id(record.variable.variable_id) == variable_id)
844 .ok_or(BrowserDomainDiscoveryError::VariableNotFound)?;
845 let facts = variable_record(datastore_id, &source);
846 Ok(BrowserDomainVariableDetailResponse {
847 protocol_version: version,
848 datastore_id,
849 variable: BrowserDomainVariableDetail {
850 facts,
851 domain: DomainRef {
852 datastore_id,
853 kind: ahri_tre_protocol::refs::ObjectKind::Domain,
854 id: domain_id,
855 },
856 value_format: source.variable.value_format,
857 tags: project_tags(datastore_id, source.tags),
858 ontology_namespace: source.variable.ontology_namespace,
859 ontology_class: source.variable.ontology_class,
860 },
861 })
862}
863
864pub fn vocabulary_detail_response(
865 source: BrowserVocabularySource,
866 datastore_id: PublicUuid,
867 domain_id: PublicUuid,
868 vocabulary_id: PublicUuid,
869) -> Result<BrowserVocabularyDetailResponse, BrowserDomainDiscoveryError> {
870 if public_vocabulary_id(source.vocabulary.vocabulary_id) != vocabulary_id
871 || public_domain_id(source.vocabulary.domain_id) != domain_id
872 {
873 return Err(BrowserDomainDiscoveryError::VocabularyNotFound);
874 }
875 Ok(BrowserVocabularyDetailResponse {
876 protocol_version: ProtocolVersion::default(),
877 datastore_id,
878 vocabulary: BrowserVocabularyDetail {
879 vocabulary: VocabularyRef {
880 datastore_id,
881 kind: ahri_tre_protocol::refs::ObjectKind::Vocabulary,
882 id: vocabulary_id,
883 },
884 domain: DomainRef {
885 datastore_id,
886 kind: ahri_tre_protocol::refs::ObjectKind::Domain,
887 id: domain_id,
888 },
889 name: source.vocabulary.name,
890 description: source.vocabulary.description,
891 },
892 })
893}
894
895pub fn vocabulary_item_collection_response(
896 mut source: BrowserVocabularySource,
897 datastore_id: PublicUuid,
898 domain_id: PublicUuid,
899 vocabulary_id: PublicUuid,
900 limit: Option<u16>,
901 raw_cursor: Option<&str>,
902) -> Result<BrowserVocabularyItemCollectionResponse, BrowserDomainDiscoveryError> {
903 if public_vocabulary_id(source.vocabulary.vocabulary_id) != vocabulary_id
904 || public_domain_id(source.vocabulary.domain_id) != domain_id
905 {
906 return Err(BrowserDomainDiscoveryError::VocabularyNotFound);
907 }
908 let version = ProtocolVersion::default();
909 let limit = limit.unwrap_or(DEFAULT_LIMIT);
910 if limit == 0 || limit > MAX_LIMIT {
911 return Err(BrowserDomainDiscoveryError::InvalidPage);
912 }
913 source
914 .items
915 .sort_by_key(|item| (item.value, item.vocabulary_item_id.0));
916 let projected = source
917 .items
918 .into_iter()
919 .map(|item| BrowserVocabularyItemRecord {
920 item: ahri_tre_protocol::refs::ObjectRef {
921 datastore_id,
922 kind: ahri_tre_protocol::refs::ObjectKind::VocabularyItem,
923 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
924 "vocabulary_item",
925 item.vocabulary_item_id.0,
926 ),
927 },
928 code: item.value,
929 label: item.code,
930 definition: item.description,
931 })
932 .collect::<Vec<_>>();
933 let snapshot = digest(&projected);
934 let (boundary, direction) = match raw_cursor {
935 None => (0, Direction::Forward),
936 Some(raw) => {
937 let cursor = decode_cursor(raw)?;
938 if cursor.datastore_id != datastore_id
939 || cursor.collection != VOCABULARY_ITEMS_COLLECTION
940 || cursor.domain_id != Some(domain_id)
941 || cursor.vocabulary_id != Some(vocabulary_id)
942 || cursor.limit != limit
943 || cursor.ordering != VOCABULARY_ITEM_ORDERING
944 || cursor.protocol_version != version.to_string()
945 || cursor.integrity != integrity(&cursor)
946 {
947 return Err(BrowserDomainDiscoveryError::InvalidCursor);
948 }
949 if cursor.snapshot != snapshot {
950 return Err(BrowserDomainDiscoveryError::StaleCursor);
951 }
952 if cursor.boundary == 0 || cursor.boundary >= projected.len() {
953 return Err(BrowserDomainDiscoveryError::InvalidCursor);
954 }
955 (cursor.boundary, cursor.direction)
956 }
957 };
958 let start = match direction {
959 Direction::Forward => boundary.min(projected.len()),
960 Direction::Backward => boundary.saturating_sub(limit as usize),
961 };
962 let end = (start + limit as usize).min(projected.len());
963 let make_cursor = |direction, boundary| {
964 let mut value = cursor(
965 datastore_id,
966 limit,
967 direction,
968 boundary,
969 &version,
970 &snapshot,
971 );
972 value.collection = VOCABULARY_ITEMS_COLLECTION.into();
973 value.ordering = VOCABULARY_ITEM_ORDERING.into();
974 value.domain_id = Some(domain_id);
975 value.vocabulary_id = Some(vocabulary_id);
976 value.integrity = integrity(&value);
977 encode_cursor(value)
978 };
979 let items = projected[start..end].to_vec();
980 Ok(BrowserVocabularyItemCollectionResponse {
981 protocol_version: version.clone(),
982 datastore_id,
983 domain: DomainRef {
984 datastore_id,
985 kind: ahri_tre_protocol::refs::ObjectKind::Domain,
986 id: domain_id,
987 },
988 vocabulary: VocabularyRef {
989 datastore_id,
990 kind: ahri_tre_protocol::refs::ObjectKind::Vocabulary,
991 id: vocabulary_id,
992 },
993 empty_state: items.is_empty().then(|| BrowserDomainEmptyState {
994 code: "no_vocabulary_items".into(),
995 message: "This Vocabulary has no categories.".into(),
996 }),
997 page: BrowserDomainPageMetadata {
998 limit,
999 returned: items.len(),
1000 ordering: VOCABULARY_ITEM_ORDERING.into(),
1001 ordering_description: VOCABULARY_ITEM_ORDERING_DESCRIPTION.into(),
1002 previous_cursor: (start > 0).then(|| make_cursor(Direction::Backward, start)),
1003 next_cursor: (end < projected.len()).then(|| make_cursor(Direction::Forward, end)),
1004 },
1005 items,
1006 })
1007}
1008
1009pub fn vocabulary_mapping_collection_response(
1010 mut source: BrowserVocabularySource,
1011 datastore_id: PublicUuid,
1012 domain_id: PublicUuid,
1013 vocabulary_id: PublicUuid,
1014 limit: Option<u16>,
1015 raw_cursor: Option<&str>,
1016) -> Result<BrowserVocabularyMappingCollectionResponse, BrowserDomainDiscoveryError> {
1017 if public_vocabulary_id(source.vocabulary.vocabulary_id) != vocabulary_id
1018 || public_domain_id(source.vocabulary.domain_id) != domain_id
1019 {
1020 return Err(BrowserDomainDiscoveryError::VocabularyNotFound);
1021 }
1022 let version = ProtocolVersion::default();
1023 let limit = limit.unwrap_or(DEFAULT_LIMIT);
1024 if limit == 0 || limit > MAX_LIMIT {
1025 return Err(BrowserDomainDiscoveryError::InvalidPage);
1026 }
1027 source.mappings.retain(|mapping| {
1028 mapping.from_item.vocabulary_id == source.vocabulary.vocabulary_id
1029 || mapping.to_item.vocabulary_id == source.vocabulary.vocabulary_id
1030 });
1031 source.mappings.sort_by_key(|mapping| {
1032 (
1033 mapping.from_item.vocabulary_id.0,
1034 mapping.from_item.value,
1035 mapping.from_item.vocabulary_item_id.0,
1036 mapping.to_item.vocabulary_id.0,
1037 mapping.to_item.value,
1038 mapping.to_item.vocabulary_item_id.0,
1039 mapping.mapping.vocabulary_mapping_id,
1040 )
1041 });
1042 let projected = source
1043 .mappings
1044 .into_iter()
1045 .map(|mapping| BrowserVocabularyMappingRecord {
1046 mapping: ahri_tre_protocol::refs::ObjectRef {
1047 datastore_id,
1048 kind: ahri_tre_protocol::refs::ObjectKind::VocabularyMapping,
1049 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
1050 "vocabulary_mapping",
1051 mapping.mapping.vocabulary_mapping_id,
1052 ),
1053 },
1054 source: mapping_endpoint(datastore_id, mapping.from_item, mapping.from_domain_id),
1055 target: mapping_endpoint(datastore_id, mapping.to_item, mapping.to_domain_id),
1056 })
1057 .collect::<Vec<_>>();
1058 let snapshot = digest(&projected);
1059 let (boundary, direction) = match raw_cursor {
1060 None => (0, Direction::Forward),
1061 Some(raw) => {
1062 let cursor = decode_cursor(raw)?;
1063 if cursor.datastore_id != datastore_id
1064 || cursor.collection != VOCABULARY_MAPPINGS_COLLECTION
1065 || cursor.domain_id != Some(domain_id)
1066 || cursor.vocabulary_id != Some(vocabulary_id)
1067 || cursor.limit != limit
1068 || cursor.ordering != VOCABULARY_MAPPING_ORDERING
1069 || cursor.protocol_version != version.to_string()
1070 || cursor.integrity != integrity(&cursor)
1071 {
1072 return Err(BrowserDomainDiscoveryError::InvalidCursor);
1073 }
1074 if cursor.snapshot != snapshot {
1075 return Err(BrowserDomainDiscoveryError::StaleCursor);
1076 }
1077 if cursor.boundary == 0 || cursor.boundary >= projected.len() {
1078 return Err(BrowserDomainDiscoveryError::InvalidCursor);
1079 }
1080 (cursor.boundary, cursor.direction)
1081 }
1082 };
1083 let start = match direction {
1084 Direction::Forward => boundary.min(projected.len()),
1085 Direction::Backward => boundary.saturating_sub(limit as usize),
1086 };
1087 let end = (start + limit as usize).min(projected.len());
1088 let make_cursor = |direction, boundary| {
1089 let mut value = cursor(
1090 datastore_id,
1091 limit,
1092 direction,
1093 boundary,
1094 &version,
1095 &snapshot,
1096 );
1097 value.collection = VOCABULARY_MAPPINGS_COLLECTION.into();
1098 value.ordering = VOCABULARY_MAPPING_ORDERING.into();
1099 value.domain_id = Some(domain_id);
1100 value.vocabulary_id = Some(vocabulary_id);
1101 value.integrity = integrity(&value);
1102 encode_cursor(value)
1103 };
1104 let mappings = projected[start..end].to_vec();
1105 Ok(BrowserVocabularyMappingCollectionResponse {
1106 protocol_version: version.clone(),
1107 datastore_id,
1108 domain: DomainRef {
1109 datastore_id,
1110 kind: ahri_tre_protocol::refs::ObjectKind::Domain,
1111 id: domain_id,
1112 },
1113 vocabulary: VocabularyRef {
1114 datastore_id,
1115 kind: ahri_tre_protocol::refs::ObjectKind::Vocabulary,
1116 id: vocabulary_id,
1117 },
1118 empty_state: mappings.is_empty().then(|| BrowserDomainEmptyState {
1119 code: "no_vocabulary_mappings".into(),
1120 message: "This Vocabulary has no mappings.".into(),
1121 }),
1122 page: BrowserDomainPageMetadata {
1123 limit,
1124 returned: mappings.len(),
1125 ordering: VOCABULARY_MAPPING_ORDERING.into(),
1126 ordering_description: VOCABULARY_MAPPING_ORDERING_DESCRIPTION.into(),
1127 previous_cursor: (start > 0).then(|| make_cursor(Direction::Backward, start)),
1128 next_cursor: (end < projected.len()).then(|| make_cursor(Direction::Forward, end)),
1129 },
1130 mappings,
1131 })
1132}
1133
1134fn mapping_endpoint(
1135 datastore_id: PublicUuid,
1136 item: ahri_tre_pgmeta::PgVocabularyItemReadModel,
1137 domain_id: DomainId,
1138) -> BrowserVocabularyMappingEndpoint {
1139 BrowserVocabularyMappingEndpoint {
1140 domain: DomainRef {
1141 datastore_id,
1142 kind: ahri_tre_protocol::refs::ObjectKind::Domain,
1143 id: public_domain_id(domain_id),
1144 },
1145 vocabulary: VocabularyRef {
1146 datastore_id,
1147 kind: ahri_tre_protocol::refs::ObjectKind::Vocabulary,
1148 id: public_vocabulary_id(item.vocabulary_id),
1149 },
1150 item: ahri_tre_protocol::refs::ObjectRef {
1151 datastore_id,
1152 kind: ahri_tre_protocol::refs::ObjectKind::VocabularyItem,
1153 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
1154 "vocabulary_item",
1155 item.vocabulary_item_id.0,
1156 ),
1157 },
1158 code: item.value,
1159 label: item.code,
1160 }
1161}
1162
1163fn variable_record(
1164 datastore_id: PublicUuid,
1165 source: &BrowserDomainVariableSourceRecord,
1166) -> BrowserDomainVariableRecord {
1167 BrowserDomainVariableRecord {
1168 variable: VariableRef {
1169 datastore_id,
1170 kind: ahri_tre_protocol::refs::ObjectKind::Variable,
1171 id: public_variable_id(source.variable.variable_id),
1172 },
1173 name: source.variable.name.clone(),
1174 value_type: source.variable.value_type.clone(),
1175 description: source.variable.description.clone(),
1176 vocabulary: source
1177 .vocabulary
1178 .as_ref()
1179 .map(|v| BrowserDomainVariableVocabulary {
1180 vocabulary: VocabularyRef {
1181 datastore_id,
1182 kind: ahri_tre_protocol::refs::ObjectKind::Vocabulary,
1183 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
1184 "vocabulary",
1185 v.vocabulary_id.0,
1186 ),
1187 },
1188 name: v.name.clone(),
1189 }),
1190 }
1191}
1192
1193fn digest<T: Serialize>(value: &T) -> String {
1194 Sha256::digest(serde_json::to_vec(value).expect("snapshot serializes"))
1195 .iter()
1196 .map(|byte| format!("{byte:02x}"))
1197 .collect()
1198}
1199
1200fn project_tags(
1201 datastore_id: PublicUuid,
1202 tags: Vec<ahri_tre_pgmeta::PgTagReadModel>,
1203) -> Vec<TagSummary> {
1204 tags.into_iter()
1205 .map(|tag| TagSummary {
1206 tag: TagRef {
1207 datastore_id,
1208 kind: ahri_tre_protocol::refs::ObjectKind::Tag,
1209 id: ahri_tre_protocol::refs::encode_scoped_integer_ref("tag", tag.tag_id),
1210 },
1211 scope: TagScope::Global,
1212 label: tag.name,
1213 })
1214 .collect()
1215}
1216
1217pub fn public_variable_id(id: VariableId) -> PublicUuid {
1218 ahri_tre_protocol::refs::encode_scoped_integer_ref("variable", id.0)
1219}
1220pub fn public_vocabulary_id(id: VocabularyId) -> PublicUuid {
1221 ahri_tre_protocol::refs::encode_scoped_integer_ref("vocabulary", id.0)
1222}
1223pub fn internal_vocabulary_id(id: PublicUuid) -> Option<VocabularyId> {
1224 ahri_tre_protocol::refs::decode_scoped_integer_ref("vocabulary", id).map(VocabularyId)
1225}
1226
1227fn project(datastore_id: PublicUuid, record: BrowserDomainSourceRecord) -> BrowserDomainRecord {
1228 BrowserDomainRecord {
1229 domain: DomainRef {
1230 datastore_id,
1231 kind: ahri_tre_protocol::refs::ObjectKind::Domain,
1232 id: public_domain_id(record.domain.domain_id),
1233 },
1234 name: record.domain.name,
1235 description: record.domain.description,
1236 uri: record.domain.uri,
1237 tags: record
1238 .tags
1239 .into_iter()
1240 .map(|tag| TagSummary {
1241 tag: TagRef {
1242 datastore_id,
1243 kind: ahri_tre_protocol::refs::ObjectKind::Tag,
1244 id: ahri_tre_protocol::refs::encode_scoped_integer_ref("tag", tag.tag_id),
1245 },
1246 scope: TagScope::Global,
1247 label: tag.name,
1248 })
1249 .collect(),
1250 }
1251}
1252pub fn public_domain_id(id: DomainId) -> PublicUuid {
1253 ahri_tre_protocol::refs::encode_scoped_integer_ref("domain", id.0)
1254}
1255pub fn internal_domain_id(id: PublicUuid) -> Option<DomainId> {
1256 ahri_tre_protocol::refs::decode_scoped_integer_ref("domain", id).map(DomainId)
1257}
1258
1259fn snapshot(source: &[BrowserDomainSourceRecord]) -> String {
1260 let mut hash = 0xcbf29ce484222325_u64;
1261 for byte in serde_json::to_vec(
1262 &source
1263 .iter()
1264 .map(|r| {
1265 (
1266 &r.domain.name,
1267 r.domain.domain_id.0,
1268 &r.domain.description,
1269 &r.domain.uri,
1270 r.tags
1271 .iter()
1272 .map(|t| (&t.name, t.tag_id))
1273 .collect::<Vec<_>>(),
1274 )
1275 })
1276 .collect::<Vec<_>>(),
1277 )
1278 .expect("snapshot serializes")
1279 {
1280 hash ^= u64::from(byte);
1281 hash = hash.wrapping_mul(0x100000001b3);
1282 }
1283 format!("{hash:016x}")
1284}
1285fn study_snapshot(source: &[ahri_tre_protocol::study::BrowserStudyFacts]) -> String {
1286 let bytes = serde_json::to_vec(source).expect("Study snapshot serializes");
1287 let digest = Sha256::digest(bytes);
1288 digest.iter().map(|byte| format!("{byte:02x}")).collect()
1289}
1290fn cursor(
1291 datastore_id: PublicUuid,
1292 limit: u16,
1293 direction: Direction,
1294 boundary: usize,
1295 version: &ProtocolVersion,
1296 snapshot: &str,
1297) -> Cursor {
1298 let mut value = Cursor {
1299 datastore_id,
1300 collection: COLLECTION.into(),
1301 limit,
1302 ordering: ORDERING.into(),
1303 direction,
1304 boundary,
1305 protocol_version: version.to_string(),
1306 snapshot: snapshot.into(),
1307 filters: BrowserDomainCollectionFilters::default(),
1308 domain_id: None,
1309 vocabulary_id: None,
1310 integrity: String::new(),
1311 };
1312 value.integrity = integrity(&value);
1313 value
1314}
1315
1316fn domain_cursor(
1317 datastore_id: PublicUuid,
1318 limit: u16,
1319 direction: Direction,
1320 boundary: usize,
1321 version: &ProtocolVersion,
1322 snapshot: &str,
1323 filters: &BrowserDomainCollectionFilters,
1324) -> Cursor {
1325 let mut value = cursor(datastore_id, limit, direction, boundary, version, snapshot);
1326 value.filters = filters.clone();
1327 value.integrity = integrity(&value);
1328 value
1329}
1330fn integrity(cursor: &Cursor) -> String {
1331 let text = format!(
1332 "{}|{}|{}|{}|{:?}|{}|{}|{}|{:?}|{:?}|{:?}",
1333 cursor.datastore_id,
1334 cursor.collection,
1335 cursor.limit,
1336 cursor.ordering,
1337 cursor.direction,
1338 cursor.boundary,
1339 cursor.protocol_version,
1340 cursor.snapshot,
1341 cursor.domain_id,
1342 cursor.vocabulary_id,
1343 cursor.filters
1344 );
1345 hmac_sha256(cursor_mac_key(), text.as_bytes())
1346 .into_iter()
1347 .map(|byte| format!("{byte:02x}"))
1348 .collect()
1349}
1350
1351fn cursor_mac_key() -> &'static [u8; 16] {
1352 CURSOR_MAC_KEY.get_or_init(|| *Uuid::new_v4().as_bytes())
1353}
1354
1355fn encode_cursor(cursor: Cursor) -> String {
1356 serde_json::to_vec(&cursor)
1357 .expect("cursor serializes")
1358 .into_iter()
1359 .map(|byte| format!("{byte:02x}"))
1360 .collect()
1361}
1362fn decode_cursor(raw: &str) -> Result<Cursor, BrowserDomainDiscoveryError> {
1363 if !raw.len().is_multiple_of(2) {
1364 return Err(BrowserDomainDiscoveryError::InvalidCursor);
1365 }
1366 let bytes = (0..raw.len())
1367 .step_by(2)
1368 .map(|index| {
1369 u8::from_str_radix(&raw[index..index + 2], 16)
1370 .map_err(|_| BrowserDomainDiscoveryError::InvalidCursor)
1371 })
1372 .collect::<Result<Vec<_>, _>>()?;
1373 serde_json::from_slice(&bytes).map_err(|_| BrowserDomainDiscoveryError::InvalidCursor)
1374}
1375
1376#[cfg(test)]
1377mod tests {
1378 use super::*;
1379 use ahri_tre_types::{KeyRole, StudyId, ValueTypeId, VocabularyId};
1380 fn record(id: i64, name: &str) -> BrowserDomainSourceRecord {
1381 BrowserDomainSourceRecord {
1382 domain: ahri_tre_pgmeta::PgDomainReadModel {
1383 domain_id: DomainId(id),
1384 name: name.into(),
1385 uri: Some(format!("https://example.test/{id}")),
1386 description: Some(format!("Domain {id}")),
1387 },
1388 tags: vec![],
1389 }
1390 }
1391 fn tagged_record(
1392 id: i64,
1393 name: &str,
1394 description: &str,
1395 tag_id: i64,
1396 tag: &str,
1397 ) -> BrowserDomainSourceRecord {
1398 let mut record = record(id, name);
1399 record.domain.description = Some(description.into());
1400 record.tags.push(ahri_tre_pgmeta::PgTagReadModel {
1401 tag_id,
1402 name: tag.into(),
1403 });
1404 record
1405 }
1406
1407 #[test]
1408 fn filters_domains_before_ordering_and_paging_with_bound_cursors() {
1409 let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(42));
1410 let filters = ahri_tre_protocol::domain::BrowserDomainCollectionFilters {
1411 text: Some(ahri_tre_protocol::domain::BrowserDomainTextFilter {
1412 mode: ahri_tre_protocol::search::TextMode::Contains,
1413 value: " health ".into(),
1414 }),
1415 any_tags: vec![ahri_tre_protocol::refs::TagSelector::Label {
1416 scope: TagScope::Global,
1417 label: ahri_tre_protocol::PublicName::new("population").unwrap(),
1418 }],
1419 };
1420 let source = vec![
1421 tagged_record(1, "Alpha", "Health", 2, "other"),
1422 tagged_record(2, "Beta", "Other", 1, "population"),
1423 tagged_record(3, "Gamma", "Health records", 1, "population"),
1424 tagged_record(4, "Zulu", "Health systems", 1, "population"),
1425 ];
1426
1427 let response =
1428 collection_response(source.clone(), datastore_id, filters, Some(1), None, None)
1429 .unwrap();
1430
1431 assert_eq!(response.domains[0].name, "Gamma");
1432 assert_eq!(response.returned_count, 1);
1433 assert!(response.has_more);
1434 assert_eq!(response.filters.text.as_ref().unwrap().value, "health");
1435
1436 let mismatch = collection_response(
1437 source,
1438 datastore_id,
1439 ahri_tre_protocol::domain::BrowserDomainCollectionFilters::default(),
1440 Some(1),
1441 response.page.next_cursor.as_deref(),
1442 None,
1443 );
1444 assert_eq!(
1445 mismatch.unwrap_err(),
1446 BrowserDomainDiscoveryError::InvalidCursor
1447 );
1448
1449 let cursor = response.page.next_cursor.as_deref().unwrap();
1450 for mismatch in [
1451 collection_response(
1452 vec![
1453 tagged_record(3, "Gamma", "Health records", 1, "population"),
1454 tagged_record(4, "Zulu", "Health systems", 1, "population"),
1455 ],
1456 PublicUuid::from_uuid(Uuid::from_u128(43)),
1457 response.filters.clone(),
1458 Some(1),
1459 Some(cursor),
1460 None,
1461 ),
1462 collection_response(
1463 vec![
1464 tagged_record(3, "Gamma", "Health records", 1, "population"),
1465 tagged_record(4, "Zulu", "Health systems", 1, "population"),
1466 ],
1467 datastore_id,
1468 response.filters.clone(),
1469 Some(2),
1470 Some(cursor),
1471 None,
1472 ),
1473 ] {
1474 assert_eq!(
1475 mismatch.unwrap_err(),
1476 BrowserDomainDiscoveryError::InvalidCursor
1477 );
1478 }
1479
1480 let mut decoded = decode_cursor(cursor).unwrap();
1481 decoded.collection = DOMAIN_VARIABLES_COLLECTION.into();
1482 decoded.integrity = integrity(&decoded);
1483 assert_eq!(
1484 collection_response(
1485 vec![
1486 tagged_record(3, "Gamma", "Health records", 1, "population"),
1487 tagged_record(4, "Zulu", "Health systems", 1, "population"),
1488 ],
1489 datastore_id,
1490 response.filters.clone(),
1491 Some(1),
1492 Some(&encode_cursor(decoded)),
1493 None,
1494 )
1495 .unwrap_err(),
1496 BrowserDomainDiscoveryError::InvalidCursor
1497 );
1498
1499 let mut decoded = decode_cursor(cursor).unwrap();
1500 decoded.protocol_version = "1.0.1".into();
1501 decoded.integrity = integrity(&decoded);
1502 assert_eq!(
1503 collection_response(
1504 vec![
1505 tagged_record(3, "Gamma", "Health records", 1, "population"),
1506 tagged_record(4, "Zulu", "Health systems", 1, "population"),
1507 ],
1508 datastore_id,
1509 response.filters,
1510 Some(1),
1511 Some(&encode_cursor(decoded)),
1512 None,
1513 )
1514 .unwrap_err(),
1515 BrowserDomainDiscoveryError::InvalidCursor
1516 );
1517 }
1518
1519 #[test]
1520 fn domain_text_filter_is_unicode_case_insensitive() {
1521 let response = collection_response(
1522 vec![tagged_record(
1523 1,
1524 "Research",
1525 "ÉTUDE health",
1526 1,
1527 "population",
1528 )],
1529 PublicUuid::from_uuid(Uuid::from_u128(42)),
1530 BrowserDomainCollectionFilters {
1531 text: Some(BrowserDomainTextFilter {
1532 mode: ahri_tre_protocol::search::TextMode::Contains,
1533 value: "étude".into(),
1534 }),
1535 any_tags: vec![],
1536 },
1537 Some(10),
1538 None,
1539 None,
1540 )
1541 .unwrap();
1542
1543 assert_eq!(response.returned_count, 1);
1544 }
1545 #[test]
1546 fn traverses_forward_and_backward_without_totals() {
1547 let id = PublicUuid::from_uuid(Uuid::from_u128(42));
1548 let source = vec![record(3, "Zulu"), record(1, "Alpha"), record(2, "Beta")];
1549 let first = collection_response(
1550 source.clone(),
1551 id,
1552 BrowserDomainCollectionFilters::default(),
1553 Some(2),
1554 None,
1555 None,
1556 )
1557 .unwrap();
1558 assert_eq!(
1559 first
1560 .domains
1561 .iter()
1562 .map(|d| d.name.as_str())
1563 .collect::<Vec<_>>(),
1564 vec!["Alpha", "Beta"]
1565 );
1566 assert!(first.page.previous_cursor.is_none());
1567 let second = collection_response(
1568 source.clone(),
1569 id,
1570 BrowserDomainCollectionFilters::default(),
1571 Some(2),
1572 first.page.next_cursor.as_deref(),
1573 None,
1574 )
1575 .unwrap();
1576 assert_eq!(second.domains[0].name, "Zulu");
1577 assert!(second.page.next_cursor.is_none());
1578 let back = collection_response(
1579 source,
1580 id,
1581 BrowserDomainCollectionFilters::default(),
1582 Some(2),
1583 second.page.previous_cursor.as_deref(),
1584 None,
1585 )
1586 .unwrap();
1587 assert_eq!(back.domains[0].name, "Alpha");
1588 }
1589 #[test]
1590 fn rejects_mismatched_and_stale_cursors() {
1591 let id = PublicUuid::from_uuid(Uuid::from_u128(42));
1592 let first = collection_response(
1593 vec![record(1, "Alpha"), record(2, "Beta")],
1594 id,
1595 BrowserDomainCollectionFilters::default(),
1596 Some(1),
1597 None,
1598 None,
1599 )
1600 .unwrap();
1601 let raw = first.page.next_cursor.as_deref();
1602 assert_eq!(
1603 collection_response(
1604 vec![record(1, "Alpha"), record(2, "Beta")],
1605 id,
1606 BrowserDomainCollectionFilters::default(),
1607 Some(2),
1608 raw,
1609 None
1610 )
1611 .unwrap_err(),
1612 BrowserDomainDiscoveryError::InvalidCursor
1613 );
1614 assert_eq!(
1615 collection_response(
1616 vec![record(1, "Alpha"), record(2, "Changed")],
1617 id,
1618 BrowserDomainCollectionFilters::default(),
1619 Some(1),
1620 raw,
1621 None
1622 )
1623 .unwrap_err(),
1624 BrowserDomainDiscoveryError::StaleCursor
1625 );
1626 }
1627 #[test]
1628 fn detail_contains_only_canonical_facts() {
1629 let id = PublicUuid::from_uuid(Uuid::from_u128(42));
1630 let response = detail_response(
1631 vec![record(7, "Health")],
1632 id,
1633 public_domain_id(DomainId(7)),
1634 None,
1635 )
1636 .unwrap();
1637 let json = serde_json::to_value(response).unwrap();
1638 assert_eq!(json["domain"]["name"], "Health");
1639 assert!(json["domain"].get("studies").is_none());
1640 assert!(json["domain"].get("variables").is_none());
1641 }
1642
1643 #[test]
1644 fn domain_studies_are_association_scoped_and_parent_bound() {
1645 let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(42));
1646 let selected = public_domain_id(DomainId(7));
1647 let other = public_domain_id(DomainId(8));
1648 let source = BrowserStudyDiscoverySource {
1649 datastore_id,
1650 studies: vec![study_record(3, "Zulu", 7), study_record(1, "Alpha", 7)],
1651 };
1652 let first =
1653 study_collection_response(source.clone(), selected, Some(1), None, None).unwrap();
1654 assert_eq!(first.studies[0].name, "Alpha");
1655 assert!(
1656 serde_json::to_value(&first.studies[0])
1657 .unwrap()
1658 .get("access_request")
1659 .is_none()
1660 );
1661 let cursor = first.page.next_cursor.as_deref().unwrap();
1662 let second =
1663 study_collection_response(source.clone(), selected, Some(1), Some(cursor), None)
1664 .unwrap();
1665 assert_eq!(second.studies[0].name, "Zulu");
1666 let previous = second.page.previous_cursor.as_deref().unwrap();
1667 let backward =
1668 study_collection_response(source.clone(), selected, Some(1), Some(previous), None)
1669 .unwrap();
1670 assert_eq!(backward.studies[0].name, "Alpha");
1671 assert_eq!(
1672 study_collection_response(source.clone(), other, Some(1), Some(cursor), None)
1673 .unwrap_err(),
1674 BrowserDomainDiscoveryError::InvalidCursor
1675 );
1676 let mut changed = source;
1677 changed.studies[1].study.name = "Changed".into();
1678 assert_eq!(
1679 study_collection_response(changed, selected, Some(1), Some(cursor), None).unwrap_err(),
1680 BrowserDomainDiscoveryError::StaleCursor
1681 );
1682 }
1683
1684 #[test]
1685 fn domain_variables_are_canonical_paginated_and_parent_bound() {
1686 let datastore = PublicUuid::from_uuid(Uuid::from_u128(42));
1687 let domain = public_domain_id(DomainId(7));
1688 let other = public_domain_id(DomainId(8));
1689 let source = vec![
1690 variable_record_source(2, "Zulu", 7),
1691 variable_record_source(1, "Alpha", 7),
1692 ];
1693 let first =
1694 variable_collection_response(source.clone(), datastore, domain, Some(1), None, None)
1695 .unwrap();
1696 assert_eq!(first.variables[0].name, "Alpha");
1697 let json = serde_json::to_value(&first.variables[0]).unwrap();
1698 assert!(json.get("key_role").is_none());
1699 assert!(json.get("row_role").is_none());
1700 assert!(json.get("note").is_none());
1701 let cursor = first.page.next_cursor.as_deref().unwrap();
1702 assert_eq!(
1703 variable_collection_response(
1704 source.clone(),
1705 datastore,
1706 other,
1707 Some(1),
1708 Some(cursor),
1709 None
1710 )
1711 .unwrap_err(),
1712 BrowserDomainDiscoveryError::InvalidCursor
1713 );
1714 let mut changed = source.clone();
1715 changed[1].variable.name = "Changed".into();
1716 assert_eq!(
1717 variable_collection_response(changed, datastore, domain, Some(1), Some(cursor), None)
1718 .unwrap_err(),
1719 BrowserDomainDiscoveryError::StaleCursor
1720 );
1721 let detail = variable_detail_response(
1722 source,
1723 datastore,
1724 domain,
1725 public_variable_id(VariableId(1)),
1726 None,
1727 )
1728 .unwrap();
1729 let json = serde_json::to_value(detail).unwrap();
1730 assert_eq!(json["variable"]["domain"]["id"], domain.to_string());
1731 assert!(json["variable"].get("dataset").is_none());
1732 assert!(json["variable"].get("note").is_none());
1733 }
1734
1735 #[test]
1736 fn vocabulary_categories_preserve_meaning_and_support_bound_traversal() {
1737 let datastore = PublicUuid::from_uuid(Uuid::from_u128(42));
1738 let domain = public_domain_id(DomainId(7));
1739 let vocabulary = public_vocabulary_id(VocabularyId(9));
1740 let source = vocabulary_source();
1741 let detail =
1742 vocabulary_detail_response(source.clone(), datastore, domain, vocabulary).unwrap();
1743 assert_eq!(detail.vocabulary.name, "Status");
1744 let first = vocabulary_item_collection_response(
1745 source.clone(),
1746 datastore,
1747 domain,
1748 vocabulary,
1749 Some(1),
1750 None,
1751 )
1752 .unwrap();
1753 assert_eq!(first.items[0].code, 10);
1754 assert_eq!(first.items[0].label, "Open");
1755 assert_eq!(first.items[0].definition.as_deref(), Some("Currently open"));
1756 let json = serde_json::to_value(&first.items[0]).unwrap();
1757 assert!(json.get("value").is_none());
1758 assert!(json.get("description").is_none());
1759 assert!(json.get("frequency").is_none());
1760 let next = first.page.next_cursor.as_deref().unwrap();
1761 let second = vocabulary_item_collection_response(
1762 source.clone(),
1763 datastore,
1764 domain,
1765 vocabulary,
1766 Some(1),
1767 Some(next),
1768 )
1769 .unwrap();
1770 assert_eq!(second.items[0].label, "Closed");
1771 assert!(second.items[0].definition.is_none());
1772 assert!(second.page.next_cursor.is_none());
1773 let previous = second.page.previous_cursor.as_deref().unwrap();
1774 assert_eq!(
1775 vocabulary_item_collection_response(
1776 source.clone(),
1777 datastore,
1778 domain,
1779 vocabulary,
1780 Some(1),
1781 Some(previous)
1782 )
1783 .unwrap()
1784 .items[0]
1785 .label,
1786 "Open"
1787 );
1788 let other = public_vocabulary_id(VocabularyId(8));
1789 assert_eq!(
1790 vocabulary_item_collection_response(
1791 source.clone(),
1792 datastore,
1793 domain,
1794 other,
1795 Some(1),
1796 Some(next)
1797 )
1798 .unwrap_err(),
1799 BrowserDomainDiscoveryError::VocabularyNotFound
1800 );
1801 let mut changed = source;
1802 changed.items[0].code = "Changed".into();
1803 assert_eq!(
1804 vocabulary_item_collection_response(
1805 changed,
1806 datastore,
1807 domain,
1808 vocabulary,
1809 Some(1),
1810 Some(next)
1811 )
1812 .unwrap_err(),
1813 BrowserDomainDiscoveryError::StaleCursor
1814 );
1815 }
1816
1817 #[test]
1818 fn vocabulary_mappings_preserve_direction_cross_domain_identity_and_traversal() {
1819 let datastore = PublicUuid::from_uuid(Uuid::from_u128(42));
1820 let domain = public_domain_id(DomainId(7));
1821 let vocabulary = public_vocabulary_id(VocabularyId(9));
1822 let source = vocabulary_mapping_source();
1823 let first = vocabulary_mapping_collection_response(
1824 source.clone(),
1825 datastore,
1826 domain,
1827 vocabulary,
1828 Some(1),
1829 None,
1830 )
1831 .unwrap();
1832 assert_eq!(first.mappings.len(), 1);
1833 assert_eq!(first.mappings[0].source.code, 10);
1834 assert_eq!(first.mappings[0].source.label, "Open");
1835 assert_eq!(first.mappings[0].target.code, 20);
1836 assert_eq!(first.mappings[0].target.label, "Done");
1837 assert_eq!(first.mappings[0].source.domain.id, domain);
1838 assert_eq!(
1839 first.mappings[0].target.domain.id,
1840 public_domain_id(DomainId(8))
1841 );
1842 assert_ne!(
1843 first.mappings[0].source.vocabulary.id,
1844 first.mappings[0].target.vocabulary.id
1845 );
1846 let json = serde_json::to_value(&first.mappings[0]).unwrap();
1847 assert!(json.get("from_value").is_none());
1848 assert!(json.get("to_code").is_none());
1849 assert!(json["source"].get("definition").is_none());
1850 let next = first.page.next_cursor.as_deref().unwrap();
1851 let terminal = vocabulary_mapping_collection_response(
1852 source.clone(),
1853 datastore,
1854 domain,
1855 vocabulary,
1856 Some(1),
1857 Some(next),
1858 )
1859 .unwrap();
1860 assert_eq!(terminal.mappings[0].source.label, "Closed");
1861 assert!(terminal.page.previous_cursor.is_some());
1862 assert!(terminal.page.next_cursor.is_some());
1863 let final_cursor = terminal.page.next_cursor.as_deref().unwrap();
1864 let final_page = vocabulary_mapping_collection_response(
1865 source.clone(),
1866 datastore,
1867 domain,
1868 vocabulary,
1869 Some(1),
1870 Some(final_cursor),
1871 )
1872 .unwrap();
1873 assert_eq!(
1874 final_page.mappings[0].source.vocabulary.id,
1875 public_vocabulary_id(VocabularyId(12))
1876 );
1877 assert_eq!(final_page.mappings[0].target.vocabulary.id, vocabulary);
1878 assert_eq!(
1879 final_page.mappings[0].source.domain.id,
1880 public_domain_id(DomainId(8))
1881 );
1882 assert_eq!(final_page.mappings[0].target.domain.id, domain);
1883 assert!(final_page.page.next_cursor.is_none());
1884 let previous = final_page.page.previous_cursor.as_deref().unwrap();
1885 assert_eq!(
1886 vocabulary_mapping_collection_response(
1887 source.clone(),
1888 datastore,
1889 domain,
1890 vocabulary,
1891 Some(1),
1892 Some(previous),
1893 )
1894 .unwrap()
1895 .mappings[0]
1896 .source
1897 .label,
1898 "Closed"
1899 );
1900 let mut empty = source.clone();
1901 empty.mappings.clear();
1902 let empty = vocabulary_mapping_collection_response(
1903 empty, datastore, domain, vocabulary, None, None,
1904 )
1905 .unwrap();
1906 assert!(empty.mappings.is_empty());
1907 assert_eq!(empty.empty_state.unwrap().code, "no_vocabulary_mappings");
1908 assert_eq!(
1909 vocabulary_mapping_collection_response(
1910 source.clone(),
1911 datastore,
1912 public_domain_id(DomainId(8)),
1913 vocabulary,
1914 Some(1),
1915 None,
1916 )
1917 .unwrap_err(),
1918 BrowserDomainDiscoveryError::VocabularyNotFound
1919 );
1920 let other = public_vocabulary_id(VocabularyId(8));
1921 assert_eq!(
1922 vocabulary_mapping_collection_response(
1923 source.clone(),
1924 datastore,
1925 domain,
1926 other,
1927 Some(1),
1928 Some(next),
1929 )
1930 .unwrap_err(),
1931 BrowserDomainDiscoveryError::VocabularyNotFound
1932 );
1933 let mut changed = source;
1934 changed.mappings[0].to_item.code = "Changed".into();
1935 assert_eq!(
1936 vocabulary_mapping_collection_response(
1937 changed.clone(),
1938 datastore,
1939 domain,
1940 vocabulary,
1941 Some(1),
1942 Some(next),
1943 )
1944 .unwrap_err(),
1945 BrowserDomainDiscoveryError::StaleCursor
1946 );
1947 changed.mappings[0].to_item.code = "Done".into();
1948 changed.mappings[0].to_domain_id = DomainId(9);
1949 assert_eq!(
1950 vocabulary_mapping_collection_response(
1951 changed,
1952 datastore,
1953 domain,
1954 vocabulary,
1955 Some(1),
1956 Some(next),
1957 )
1958 .unwrap_err(),
1959 BrowserDomainDiscoveryError::StaleCursor
1960 );
1961 }
1962
1963 fn vocabulary_source() -> BrowserVocabularySource {
1964 BrowserVocabularySource {
1965 vocabulary: ahri_tre_pgmeta::PgVocabularyReadModel {
1966 vocabulary_id: VocabularyId(9),
1967 domain_id: DomainId(7),
1968 name: "Status".into(),
1969 description: Some("Canonical status".into()),
1970 },
1971 items: vec![
1972 ahri_tre_pgmeta::PgVocabularyItemReadModel {
1973 vocabulary_item_id: ahri_tre_types::VocabularyItemId(2),
1974 vocabulary_id: VocabularyId(9),
1975 value: 10,
1976 code: "Closed".into(),
1977 description: None,
1978 },
1979 ahri_tre_pgmeta::PgVocabularyItemReadModel {
1980 vocabulary_item_id: ahri_tre_types::VocabularyItemId(1),
1981 vocabulary_id: VocabularyId(9),
1982 value: 10,
1983 code: "Open".into(),
1984 description: Some("Currently open".into()),
1985 },
1986 ],
1987 mappings: vec![],
1988 }
1989 }
1990
1991 fn vocabulary_mapping_source() -> BrowserVocabularySource {
1992 let mut source = vocabulary_source();
1993 source.items = vec![];
1994 source.mappings = vec![
1995 BrowserVocabularyMappingSourceRecord {
1996 mapping: ahri_tre_pgmeta::PgVocabularyMappingReadModel {
1997 vocabulary_mapping_id: 2,
1998 from_vocabulary_item: ahri_tre_types::VocabularyItemId(1),
1999 to_vocabulary_item: ahri_tre_types::VocabularyItemId(4),
2000 },
2001 from_item: ahri_tre_pgmeta::PgVocabularyItemReadModel {
2002 vocabulary_item_id: ahri_tre_types::VocabularyItemId(1),
2003 vocabulary_id: VocabularyId(9),
2004 value: 10,
2005 code: "Open".into(),
2006 description: Some("Not projected here".into()),
2007 },
2008 from_domain_id: DomainId(7),
2009 to_item: ahri_tre_pgmeta::PgVocabularyItemReadModel {
2010 vocabulary_item_id: ahri_tre_types::VocabularyItemId(4),
2011 vocabulary_id: VocabularyId(12),
2012 value: 20,
2013 code: "Done".into(),
2014 description: Some("Also not projected here".into()),
2015 },
2016 to_domain_id: DomainId(8),
2017 },
2018 BrowserVocabularyMappingSourceRecord {
2019 mapping: ahri_tre_pgmeta::PgVocabularyMappingReadModel {
2020 vocabulary_mapping_id: 3,
2021 from_vocabulary_item: ahri_tre_types::VocabularyItemId(3),
2022 to_vocabulary_item: ahri_tre_types::VocabularyItemId(5),
2023 },
2024 from_item: ahri_tre_pgmeta::PgVocabularyItemReadModel {
2025 vocabulary_item_id: ahri_tre_types::VocabularyItemId(3),
2026 vocabulary_id: VocabularyId(9),
2027 value: 10,
2028 code: "Closed".into(),
2029 description: None,
2030 },
2031 from_domain_id: DomainId(7),
2032 to_item: ahri_tre_pgmeta::PgVocabularyItemReadModel {
2033 vocabulary_item_id: ahri_tre_types::VocabularyItemId(5),
2034 vocabulary_id: VocabularyId(12),
2035 value: 21,
2036 code: "Pending".into(),
2037 description: None,
2038 },
2039 to_domain_id: DomainId(8),
2040 },
2041 BrowserVocabularyMappingSourceRecord {
2042 mapping: ahri_tre_pgmeta::PgVocabularyMappingReadModel {
2043 vocabulary_mapping_id: 1,
2044 from_vocabulary_item: ahri_tre_types::VocabularyItemId(6),
2045 to_vocabulary_item: ahri_tre_types::VocabularyItemId(2),
2046 },
2047 from_item: ahri_tre_pgmeta::PgVocabularyItemReadModel {
2048 vocabulary_item_id: ahri_tre_types::VocabularyItemId(6),
2049 vocabulary_id: VocabularyId(12),
2050 value: 30,
2051 code: "Referred".into(),
2052 description: None,
2053 },
2054 from_domain_id: DomainId(8),
2055 to_item: ahri_tre_pgmeta::PgVocabularyItemReadModel {
2056 vocabulary_item_id: ahri_tre_types::VocabularyItemId(2),
2057 vocabulary_id: VocabularyId(9),
2058 value: 10,
2059 code: "Open".into(),
2060 description: None,
2061 },
2062 to_domain_id: DomainId(7),
2063 },
2064 ];
2065 source
2066 }
2067
2068 fn variable_record_source(
2069 id: i64,
2070 name: &str,
2071 domain: i64,
2072 ) -> BrowserDomainVariableSourceRecord {
2073 BrowserDomainVariableSourceRecord {
2074 variable: ahri_tre_pgmeta::PgVariableReadModel {
2075 variable_id: VariableId(id),
2076 domain_id: DomainId(domain),
2077 name: name.into(),
2078 value_type_id: ValueTypeId(1),
2079 value_type: "xsd:string".into(),
2080 value_format: Some("token".into()),
2081 vocabulary_id: Some(VocabularyId(9)),
2082 key_role: KeyRole::Record,
2083 description: Some(format!("{name} description")),
2084 note: Some("protected".into()),
2085 ontology_namespace: Some("https://example.test/ontology/".into()),
2086 ontology_class: Some("C1".into()),
2087 },
2088 vocabulary: Some(ahri_tre_pgmeta::PgVocabularyReadModel {
2089 vocabulary_id: VocabularyId(9),
2090 domain_id: DomainId(domain),
2091 name: "Status".into(),
2092 description: None,
2093 }),
2094 tags: vec![],
2095 }
2096 }
2097
2098 fn study_record(
2099 id: u128,
2100 name: &str,
2101 domain_id: i64,
2102 ) -> crate::browser_study_discovery::BrowserStudyDiscoverySourceRecord {
2103 crate::browser_study_discovery::BrowserStudyDiscoverySourceRecord {
2104 study: ahri_tre_pgmeta::PgStudyReadModel {
2105 study_id: StudyId(Uuid::from_u128(id)),
2106 name: name.into(),
2107 description: Some(format!("{name} description")),
2108 documentation: None,
2109 agent_instructions: Some("private".into()),
2110 external_id: None,
2111 study_type_id: None,
2112 date_created: None,
2113 created_by: Some("private@example.org".into()),
2114 },
2115 domains: vec![ahri_tre_pgmeta::PgDomainReadModel {
2116 domain_id: DomainId(domain_id),
2117 name: format!("Domain {domain_id}"),
2118 uri: None,
2119 description: None,
2120 }],
2121 tags: vec![],
2122 access_conditions: vec![],
2123 }
2124 }
2125}