Skip to main content

ahri_tre_protocol/
domain.rs

1use crate::{
2    ProtocolVersion, PublicName, PublicUuid,
3    pagination::PageRequest,
4    public_error::{ProtocolError, ValidationIssue, ValidationIssueCode},
5    refs::{DomainRef, SessionSelector, TagSelector, TagSummary, VariableRef, VocabularyRef},
6    search::TextMode,
7    session::SessionStatusPayload,
8    study::BrowserStudyFacts,
9};
10use serde::{Deserialize, Serialize};
11
12pub const BROWSER_DOMAIN_ERROR_TARGET_DATASTORE: &str = "datastore";
13pub const BROWSER_DOMAIN_ERROR_TARGET_DATASTORE_UNAVAILABLE: &str = "datastore_unavailable";
14pub const BROWSER_DOMAIN_ERROR_TARGET_STUDY_DATASTORE_UNAVAILABLE: &str =
15    "study_datastore_unavailable";
16pub const BROWSER_DOMAIN_ERROR_TARGET_DOMAIN: &str = "domain";
17pub const BROWSER_DOMAIN_ERROR_TARGET_VARIABLE: &str = "variable";
18pub const BROWSER_DOMAIN_ERROR_TARGET_VOCABULARY: &str = "vocabulary";
19pub const BROWSER_DOMAIN_ERROR_TARGET_CURSOR: &str = "cursor";
20pub const BROWSER_DOMAIN_ERROR_TARGET_FILTERS: &str = "filters";
21pub const BROWSER_DOMAIN_ERROR_TARGET_PAGE: &str = "page";
22pub const BROWSER_DOMAIN_ERROR_TARGET_SERVICE: &str = "browser_domain_discovery";
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
26pub enum DomainSelector {
27    Id { domain: DomainRef },
28    Name { name: PublicName },
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct DomainSummary {
33    pub domain: DomainRef,
34    pub name: String,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub description: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub uri: Option<String>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct DomainDetail {
43    pub summary: DomainSummary,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct ListDomainsRequest {
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub session: Option<SessionSelector>,
50    pub page: PageRequest,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct GetDomainRequest {
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub session: Option<SessionSelector>,
57    pub domain: DomainSelector,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct AddDomainRequest {
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub session: Option<SessionSelector>,
65    pub name: PublicName,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub description: Option<String>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub uri: Option<String>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct DomainListResponse {
74    pub session: SessionStatusPayload,
75    pub domains: Vec<DomainSummary>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct DomainGetResponse {
80    pub session: SessionStatusPayload,
81    pub domain: DomainDetail,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct DomainAddResponse {
86    pub session: SessionStatusPayload,
87    pub domain: DomainDetail,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct BrowserDomainCollectionRequest {
92    pub datastore_id: PublicUuid,
93    /// Optional collection filters. Text is combined with the selected tags as
94    /// `text AND any tag`; an absent text value or empty tag group is ignored.
95    #[serde(default)]
96    pub filters: BrowserDomainCollectionFilters,
97    pub page: PageRequest,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub requested_protocol_version: Option<String>,
100}
101
102/// Normalized filters for one Datastore's browser-visible Domain collection.
103///
104/// Text and tags combine as `text AND any tag`; either absent group is not
105/// applied. Tag selectors are sorted and deduplicated during validation.
106#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
107pub struct BrowserDomainCollectionFilters {
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub text: Option<BrowserDomainTextFilter>,
110    #[serde(default)]
111    pub any_tags: Vec<TagSelector>,
112}
113
114/// One bounded, case-insensitive text filter over public Domain text fields.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct BrowserDomainTextFilter {
117    pub mode: TextMode,
118    pub value: String,
119}
120
121impl BrowserDomainCollectionRequest {
122    /// Trims text and canonicalizes tag order before enforcing collection
123    /// filter bounds. Text and tag labels are bounded by Unicode character
124    /// count, and the raw term count is checked before tag deduplication.
125    ///
126    /// # Errors
127    ///
128    /// Returns a field-addressable [`ProtocolError`] when text is empty or
129    /// longer than the protocol limit, or when the combined term count exceeds
130    /// the search-predicate limit.
131    #[allow(clippy::result_large_err)]
132    pub fn normalize_and_validate(&mut self) -> Result<(), ProtocolError> {
133        let mut issues = Vec::new();
134        if let Some(text) = self.filters.text.as_mut() {
135            text.value = text.value.trim().to_string();
136            if text.value.is_empty() {
137                issues.push(ValidationIssue::new(
138                    "filters.text.value",
139                    ValidationIssueCode::TooShort,
140                    "Domain collection text filter must not be empty",
141                ));
142            }
143            if text.value.chars().count() > crate::search::MAX_TEXT_VALUE_LENGTH {
144                issues.push(ValidationIssue::new(
145                    "filters.text.value",
146                    ValidationIssueCode::TooLong,
147                    format!(
148                        "Domain collection text filter must be at most {} characters",
149                        crate::search::MAX_TEXT_VALUE_LENGTH
150                    ),
151                ));
152            }
153        }
154        for (index, selector) in self.filters.any_tags.iter().enumerate() {
155            if let TagSelector::Label { label, .. } = selector
156                && label.as_str().chars().count() > crate::search::MAX_TEXT_VALUE_LENGTH
157            {
158                issues.push(ValidationIssue::new(
159                    format!("filters.any_tags.{index}.label"),
160                    ValidationIssueCode::TooLong,
161                    format!(
162                        "Domain collection tag labels must be at most {} characters",
163                        crate::search::MAX_TEXT_VALUE_LENGTH
164                    ),
165                ));
166            }
167        }
168        let count = usize::from(self.filters.text.is_some()) + self.filters.any_tags.len();
169        if count > crate::search::MAX_SEARCH_PREDICATES {
170            issues.push(ValidationIssue::new(
171                "filters",
172                ValidationIssueCode::TooManyItems,
173                format!(
174                    "Domain collection filters must contain at most {} terms",
175                    crate::search::MAX_SEARCH_PREDICATES
176                ),
177            ));
178        }
179        self.filters.any_tags.sort_by_key(tag_selector_key);
180        self.filters
181            .any_tags
182            .dedup_by(|left, right| tag_selector_key(left) == tag_selector_key(right));
183        if issues.is_empty() {
184            Ok(())
185        } else {
186            Err(ProtocolError::validation(
187                "Domain collection filters are invalid",
188                issues,
189            ))
190        }
191    }
192}
193
194fn tag_selector_key(selector: &TagSelector) -> String {
195    match selector {
196        TagSelector::Id { tag } => format!("id:{}:{:?}:{}", tag.datastore_id, tag.kind, tag.id),
197        TagSelector::Label { scope, label } => format!("label:{scope:?}:{}", label.as_str()),
198    }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct BrowserDomainDetailRequest {
203    pub datastore_id: PublicUuid,
204    pub domain: DomainRef,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub requested_protocol_version: Option<String>,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210pub struct BrowserDomainStudyCollectionRequest {
211    pub datastore_id: PublicUuid,
212    pub domain: DomainRef,
213    pub page: PageRequest,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub requested_protocol_version: Option<String>,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219pub struct BrowserDomainVariableCollectionRequest {
220    pub datastore_id: PublicUuid,
221    pub domain: DomainRef,
222    pub page: PageRequest,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226pub struct BrowserDomainVariableDetailRequest {
227    pub datastore_id: PublicUuid,
228    pub domain: DomainRef,
229    pub variable: VariableRef,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct BrowserVocabularyDetailRequest {
234    pub datastore_id: PublicUuid,
235    pub domain: DomainRef,
236    pub vocabulary: VocabularyRef,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct BrowserVocabularyItemCollectionRequest {
241    pub datastore_id: PublicUuid,
242    pub domain: DomainRef,
243    pub vocabulary: VocabularyRef,
244    pub page: PageRequest,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248pub struct BrowserVocabularyMappingCollectionRequest {
249    pub datastore_id: PublicUuid,
250    pub domain: DomainRef,
251    pub vocabulary: VocabularyRef,
252    pub page: PageRequest,
253}
254
255/// Browser-safe canonical Domain facts. Study and Variable collections are
256/// deliberately separate resources and are never embedded here.
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258pub struct BrowserDomainRecord {
259    pub domain: DomainRef,
260    pub name: String,
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub description: Option<String>,
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub uri: Option<String>,
265    #[serde(default)]
266    pub tags: Vec<TagSummary>,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270pub struct BrowserDomainPageMetadata {
271    pub limit: u16,
272    pub returned: usize,
273    pub ordering: String,
274    pub ordering_description: String,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub previous_cursor: Option<String>,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub next_cursor: Option<String>,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282pub struct BrowserDomainEmptyState {
283    pub code: String,
284    pub message: String,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288pub struct BrowserDomainCollectionResponse {
289    pub protocol_version: ProtocolVersion,
290    pub datastore_id: PublicUuid,
291    #[serde(default)]
292    pub filters: BrowserDomainCollectionFilters,
293    #[serde(default)]
294    pub domains: Vec<BrowserDomainRecord>,
295    /// Number of Domain records in this returned page.
296    pub returned_count: usize,
297    /// Whether a following page exists in the active filtered sequence.
298    pub has_more: bool,
299    pub page: BrowserDomainPageMetadata,
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub empty_state: Option<BrowserDomainEmptyState>,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305pub struct BrowserDomainDetailResponse {
306    pub protocol_version: ProtocolVersion,
307    pub datastore_id: PublicUuid,
308    pub domain: BrowserDomainRecord,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312pub struct BrowserDomainStudyCollectionResponse {
313    pub protocol_version: ProtocolVersion,
314    pub datastore_id: PublicUuid,
315    pub domain: DomainRef,
316    #[serde(default)]
317    pub studies: Vec<BrowserStudyFacts>,
318    pub page: BrowserDomainPageMetadata,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub empty_state: Option<BrowserDomainEmptyState>,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct BrowserDomainVariableVocabulary {
325    pub vocabulary: VocabularyRef,
326    pub name: String,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330pub struct BrowserDomainVariableRecord {
331    pub variable: VariableRef,
332    pub name: String,
333    pub value_type: String,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub description: Option<String>,
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub vocabulary: Option<BrowserDomainVariableVocabulary>,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct BrowserDomainVariableCollectionResponse {
342    pub protocol_version: ProtocolVersion,
343    pub datastore_id: PublicUuid,
344    pub domain: DomainRef,
345    #[serde(default)]
346    pub variables: Vec<BrowserDomainVariableRecord>,
347    pub page: BrowserDomainPageMetadata,
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub empty_state: Option<BrowserDomainEmptyState>,
350}
351
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353pub struct BrowserDomainVariableDetail {
354    #[serde(flatten)]
355    pub facts: BrowserDomainVariableRecord,
356    pub domain: DomainRef,
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub value_format: Option<String>,
359    #[serde(default)]
360    pub tags: Vec<TagSummary>,
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub ontology_namespace: Option<String>,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub ontology_class: Option<String>,
365}
366
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368pub struct BrowserDomainVariableDetailResponse {
369    pub protocol_version: ProtocolVersion,
370    pub datastore_id: PublicUuid,
371    pub variable: BrowserDomainVariableDetail,
372}
373
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375pub struct BrowserVocabularyDetail {
376    pub vocabulary: VocabularyRef,
377    pub domain: DomainRef,
378    pub name: String,
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub description: Option<String>,
381}
382
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384pub struct BrowserVocabularyDetailResponse {
385    pub protocol_version: ProtocolVersion,
386    pub datastore_id: PublicUuid,
387    pub vocabulary: BrowserVocabularyDetail,
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391pub struct BrowserVocabularyItemRecord {
392    pub item: crate::refs::VocabularyItemRef,
393    pub code: i32,
394    pub label: String,
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub definition: Option<String>,
397}
398
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400pub struct BrowserVocabularyItemCollectionResponse {
401    pub protocol_version: ProtocolVersion,
402    pub datastore_id: PublicUuid,
403    pub domain: DomainRef,
404    pub vocabulary: VocabularyRef,
405    #[serde(default)]
406    pub items: Vec<BrowserVocabularyItemRecord>,
407    pub page: BrowserDomainPageMetadata,
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub empty_state: Option<BrowserDomainEmptyState>,
410}
411
412#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
413pub struct BrowserVocabularyMappingEndpoint {
414    pub domain: DomainRef,
415    pub vocabulary: VocabularyRef,
416    pub item: crate::refs::VocabularyItemRef,
417    pub code: i32,
418    pub label: String,
419}
420
421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
422pub struct BrowserVocabularyMappingRecord {
423    pub mapping: crate::refs::VocabularyMappingRef,
424    pub source: BrowserVocabularyMappingEndpoint,
425    pub target: BrowserVocabularyMappingEndpoint,
426}
427
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
429pub struct BrowserVocabularyMappingCollectionResponse {
430    pub protocol_version: ProtocolVersion,
431    pub datastore_id: PublicUuid,
432    pub domain: DomainRef,
433    pub vocabulary: VocabularyRef,
434    #[serde(default)]
435    pub mappings: Vec<BrowserVocabularyMappingRecord>,
436    pub page: BrowserDomainPageMetadata,
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub empty_state: Option<BrowserDomainEmptyState>,
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use crate::{
445        pagination::PageRequest,
446        refs::{TagRef, TagScope, TagSelector},
447        search::TextMode,
448    };
449    use uuid::Uuid;
450
451    #[test]
452    fn domain_collection_filters_are_normalized_and_bounded() {
453        let mut request = BrowserDomainCollectionRequest {
454            datastore_id: PublicUuid::from_uuid(Uuid::from_u128(42)),
455            filters: BrowserDomainCollectionFilters {
456                text: Some(BrowserDomainTextFilter {
457                    mode: TextMode::Contains,
458                    value: "  health  ".into(),
459                }),
460                any_tags: vec![
461                    TagSelector::Id {
462                        tag: TagRef {
463                            datastore_id: crate::PublicUuid::from_uuid(uuid::Uuid::from_u128(5)),
464                            kind: crate::refs::ObjectKind::Tag,
465                            id: PublicUuid::from_uuid(Uuid::from_u128(9)),
466                        },
467                    },
468                    TagSelector::Label {
469                        scope: TagScope::Global,
470                        label: PublicName::new("population").unwrap(),
471                    },
472                    TagSelector::Id {
473                        tag: TagRef {
474                            datastore_id: crate::PublicUuid::from_uuid(uuid::Uuid::from_u128(5)),
475                            kind: crate::refs::ObjectKind::Tag,
476                            id: PublicUuid::from_uuid(Uuid::from_u128(9)),
477                        },
478                    },
479                ],
480            },
481            page: PageRequest::default(),
482            requested_protocol_version: None,
483        };
484
485        request.normalize_and_validate().unwrap();
486
487        assert_eq!(request.filters.text.unwrap().value, "health");
488        assert_eq!(request.filters.any_tags.len(), 2);
489    }
490
491    #[test]
492    fn domain_collection_rejects_empty_and_oversized_text_filters() {
493        for value in [
494            "   ".to_string(),
495            "x".repeat(crate::search::MAX_TEXT_VALUE_LENGTH + 1),
496        ] {
497            let mut request = BrowserDomainCollectionRequest {
498                datastore_id: PublicUuid::from_uuid(Uuid::from_u128(42)),
499                filters: BrowserDomainCollectionFilters {
500                    text: Some(BrowserDomainTextFilter {
501                        mode: TextMode::Contains,
502                        value,
503                    }),
504                    any_tags: vec![],
505                },
506                page: PageRequest::default(),
507                requested_protocol_version: None,
508            };
509
510            assert!(request.normalize_and_validate().is_err());
511        }
512    }
513
514    #[test]
515    fn domain_collection_filter_bounds_count_unicode_characters() {
516        let mut accepted = BrowserDomainCollectionRequest {
517            datastore_id: PublicUuid::from_uuid(Uuid::from_u128(42)),
518            filters: BrowserDomainCollectionFilters {
519                text: Some(BrowserDomainTextFilter {
520                    mode: TextMode::Contains,
521                    value: "é".repeat(crate::search::MAX_TEXT_VALUE_LENGTH),
522                }),
523                any_tags: vec![],
524            },
525            page: PageRequest::default(),
526            requested_protocol_version: None,
527        };
528        assert!(accepted.normalize_and_validate().is_ok());
529
530        accepted.filters.text.as_mut().unwrap().value =
531            "é".repeat(crate::search::MAX_TEXT_VALUE_LENGTH + 1);
532        assert!(accepted.normalize_and_validate().is_err());
533    }
534
535    #[test]
536    fn domain_collection_rejects_oversized_tag_labels_and_raw_duplicate_terms() {
537        let oversized_label =
538            PublicName::new("é".repeat(crate::search::MAX_TEXT_VALUE_LENGTH + 1)).unwrap();
539        let duplicate = TagSelector::Id {
540            tag: TagRef {
541                datastore_id: crate::PublicUuid::from_uuid(uuid::Uuid::from_u128(5)),
542                kind: crate::refs::ObjectKind::Tag,
543                id: PublicUuid::from_uuid(Uuid::from_u128(9)),
544            },
545        };
546        for any_tags in [
547            vec![TagSelector::Label {
548                scope: TagScope::Global,
549                label: oversized_label,
550            }],
551            vec![duplicate; crate::search::MAX_SEARCH_PREDICATES + 1],
552        ] {
553            let mut request = BrowserDomainCollectionRequest {
554                datastore_id: PublicUuid::from_uuid(Uuid::from_u128(42)),
555                filters: BrowserDomainCollectionFilters {
556                    text: None,
557                    any_tags,
558                },
559                page: PageRequest::default(),
560                requested_protocol_version: None,
561            };
562
563            assert!(request.normalize_and_validate().is_err());
564        }
565    }
566
567    #[test]
568    fn domain_collection_validation_uses_a_standard_error_type() {
569        fn assert_error<T: std::error::Error>() {}
570
571        assert_error::<ProtocolError>();
572    }
573}