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 #[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct BrowserDomainTextFilter {
117 pub mode: TextMode,
118 pub value: String,
119}
120
121impl BrowserDomainCollectionRequest {
122 #[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#[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 pub returned_count: usize,
297 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}