1use crate::{
2 ProtocolVersion, PublicName,
3 domain::{DomainSelector, DomainSummary},
4 pagination::PageRequest,
5 public_error::{ProtocolError, ValidationIssue, ValidationIssueCode},
6 refs::SessionSelector,
7 refs::{
8 AssetRef, DataFileRef, DatasetRef, DomainRef, StudyRef, TagSelector, TagSummary, VersionRef,
9 },
10 search::{PredicateSet, SearchCursorBinding, TagPredicate, TextMode, TextPredicate},
11 session::SessionStatusPayload,
12};
13use serde::{Deserialize, Serialize};
14
15pub const BROWSER_STUDY_ERROR_TARGET_DATASTORE: &str = "datastore";
16pub const BROWSER_STUDY_ERROR_TARGET_STUDY: &str = "study";
17pub const BROWSER_STUDY_ERROR_TARGET_SERVICE: &str = "service";
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(untagged)]
21pub enum StudyTypeSelector {
22 Reference(crate::refs::ObjectRef),
23 Symbol(String),
24}
25impl From<String> for StudyTypeSelector {
26 fn from(value: String) -> Self {
27 Self::Symbol(value)
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
33pub enum StudySelector {
34 Id {
35 study: StudyRef,
36 },
37 Name {
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 domain: Option<DomainSelector>,
40 name: PublicName,
41 },
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct StudySummary {
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub study_type: Option<crate::refs::ObjectRef>,
48 pub study: StudyRef,
49 pub name: String,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub external_id: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub title: Option<String>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub description: Option<String>,
56 #[serde(default)]
57 pub domains: Vec<DomainRef>,
58 #[serde(default)]
59 pub tags: Vec<TagSummary>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct StudyDetail {
64 pub summary: StudySummary,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct ListStudiesRequest {
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub session: Option<SessionSelector>,
71 pub page: PageRequest,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct SearchStudiesRequest {
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub session: Option<SessionSelector>,
78 pub predicates: PredicateSet<StudyPredicate>,
79 pub page: PageRequest,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct StudySearchCursorAnchor {
84 pub study: StudyRef,
85 pub name: String,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct StudySearchCursorQuery {
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub session: Option<SessionSelector>,
92 pub predicates: PredicateSet<StudyPredicate>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct StudySearchCursor {
97 pub binding: SearchCursorBinding<StudySearchCursorQuery>,
98 pub after: StudySearchCursorAnchor,
99}
100
101#[allow(clippy::result_large_err)]
102impl SearchStudiesRequest {
103 pub fn validate(&mut self, protocol_version: &ProtocolVersion) -> Result<(), ProtocolError> {
104 let mut issues = Vec::new();
105 collect_predicate_set_issues("body.predicates", &self.predicates, &mut issues);
106 collect_study_predicate_issues(&mut self.predicates, &mut issues);
107 collect_page_issues("body.page.limit", self.page.limit, &mut issues);
108 collect_cursor_issues(self, protocol_version, &mut issues);
109
110 if issues.is_empty() {
111 Ok(())
112 } else {
113 Err(ProtocolError::validation(
114 "study search request is invalid",
115 issues,
116 ))
117 }
118 }
119
120 pub fn decoded_cursor(
121 &self,
122 protocol_version: &ProtocolVersion,
123 ) -> Result<Option<StudySearchCursorAnchor>, ProtocolError> {
124 match self.page.cursor.as_deref() {
125 None => Ok(None),
126 Some(raw_cursor) => {
127 let cursor: StudySearchCursor =
128 serde_json::from_str(raw_cursor).map_err(|error| {
129 ProtocolError::validation(
130 "study search request is invalid",
131 vec![ValidationIssue::new(
132 "body.page.cursor",
133 ValidationIssueCode::InvalidFormat,
134 format!("study search cursor is invalid: {error}"),
135 )],
136 )
137 })?;
138 let expected = self.cursor_binding(protocol_version);
139 if cursor.binding != expected {
140 return Err(ProtocolError::validation(
141 "study search request is invalid",
142 vec![ValidationIssue::new(
143 "body.page.cursor",
144 ValidationIssueCode::Conflict,
145 "study search cursor does not match the request predicates, page limit, or protocol version",
146 )],
147 ));
148 }
149 Ok(Some(cursor.after))
150 }
151 }
152 }
153
154 pub fn encode_cursor(
155 &self,
156 protocol_version: &ProtocolVersion,
157 after: StudySearchCursorAnchor,
158 ) -> Result<String, ProtocolError> {
159 serde_json::to_string(&StudySearchCursor {
160 binding: self.cursor_binding(protocol_version),
161 after,
162 })
163 .map_err(|error| {
164 ProtocolError::new(
165 crate::public_error::ProtocolErrorCode::Internal,
166 format!("failed to encode study search cursor: {error}"),
167 )
168 })
169 }
170
171 fn cursor_binding(
172 &self,
173 protocol_version: &ProtocolVersion,
174 ) -> SearchCursorBinding<StudySearchCursorQuery> {
175 SearchCursorBinding {
176 query: StudySearchCursorQuery {
177 session: self.session.clone(),
178 predicates: self.predicates.clone(),
179 },
180 page_limit: self.page.limit,
181 protocol_version: protocol_version.to_string(),
182 }
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct GetStudyRequest {
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub session: Option<SessionSelector>,
190 pub study: StudySelector,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct AddStudyRequest {
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub session: Option<SessionSelector>,
198 pub name: PublicName,
199 pub external_id: String,
200 pub study_type: StudyTypeSelector,
201 pub domain: DomainSelector,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub description: Option<String>,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207pub struct AddStudyDomainRequest {
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub session: Option<SessionSelector>,
210 pub study: StudySelector,
211 pub domain: DomainSelector,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215pub struct StudyAccessListRequest {
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub session: Option<SessionSelector>,
218 pub study: StudySelector,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct StudyAccessGrantRequest {
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub session: Option<SessionSelector>,
225 pub study: StudySelector,
226 pub target_user: String,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230pub struct StudyAccessRevokeRequest {
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub session: Option<SessionSelector>,
233 pub study: StudySelector,
234 pub target_user: String,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238pub struct StudyCustodiansListRequest {
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub session: Option<SessionSelector>,
241 pub study: StudySelector,
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct StudyCustodianMutationRequest {
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub session: Option<SessionSelector>,
248 pub study: StudySelector,
249 pub target_user: String,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct StudyDuoListRequest {
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub session: Option<SessionSelector>,
256 pub study: StudySelector,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260pub struct StudyDuoRestrictionInput {
261 pub code: Option<String>,
262 pub duo_id: Option<String>,
263 pub qualifier_ontology_namespace: Option<String>,
264 pub qualifier_ontology_id: Option<String>,
265 pub qualifier_text: Option<String>,
266 pub qualifier_date: Option<chrono::NaiveDate>,
267 pub qualifier_integer: Option<i32>,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271pub struct StudyDuoReplaceRequest {
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub session: Option<SessionSelector>,
274 pub study: StudySelector,
275 #[serde(default)]
276 pub restrictions: Vec<StudyDuoRestrictionInput>,
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct BrowserStudyDiscoveryRequest {
281 pub datastore_id: crate::PublicUuid,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285pub struct BrowserStudyDetailRequest {
286 pub datastore_id: crate::PublicUuid,
287 pub study_id: crate::PublicUuid,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub struct BrowserStudySearchRequest {
292 pub datastore_id: crate::PublicUuid,
293 pub search: BrowserStudySearchInput,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(deny_unknown_fields)]
302pub struct BrowserStudySearchInput {
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub session: Option<SessionSelector>,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub predicates: Option<PredicateSet<StudyPredicate>>,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub query: Option<BrowserStudySearchQuery>,
309 pub page: PageRequest,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313pub struct BrowserStudySearchQuery {
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub text: Option<BrowserStudyTextQuery>,
316 #[serde(default)]
317 pub facets: BrowserStudyFacetGroups,
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321pub struct BrowserStudyTextQuery {
322 pub mode: TextMode,
323 pub value: String,
324}
325
326#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
327pub struct BrowserStudyFacetGroups {
328 #[serde(default)]
329 pub any_tags: Vec<TagSelector>,
330 #[serde(default)]
331 pub any_domains: Vec<DomainSelector>,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335pub struct BrowserStudyAccessCondition {
336 pub duo_id: String,
337 pub code: String,
338 pub label: String,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub qualifier_ontology_namespace: Option<String>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub qualifier_ontology_id: Option<String>,
343 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub qualifier_text: Option<String>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub qualifier_date: Option<chrono::NaiveDate>,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub qualifier_integer: Option<i32>,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352pub struct BrowserStudyAccessRequestReference {
353 pub datastore_id: crate::PublicUuid,
354 pub study: StudyRef,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358pub struct BrowserStudyDiscoveryRecord {
359 #[serde(flatten)]
360 pub facts: BrowserStudyFacts,
361 #[serde(default)]
362 pub domains: Vec<DomainSummary>,
363 pub access_request: BrowserStudyAccessRequestReference,
364}
365
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369pub struct BrowserStudyFacts {
370 pub study: StudyRef,
371 pub name: String,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub external_id: Option<String>,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub description: Option<String>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub documentation: Option<String>,
378 #[serde(default)]
379 pub tags: Vec<TagSummary>,
380 #[serde(default)]
381 pub access_conditions: Vec<BrowserStudyAccessCondition>,
382}
383
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385pub struct BrowserStudyDiscoveryEmptyState {
386 pub code: String,
387 pub message: String,
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
396pub struct BrowserStudyDiscoveryResponse {
397 pub authority: String,
398 pub datastore_id: crate::PublicUuid,
399 #[serde(default)]
400 pub studies: Vec<BrowserStudyDiscoveryRecord>,
401 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub empty_state: Option<BrowserStudyDiscoveryEmptyState>,
403 pub content_derived_summaries: BrowserContentSummaryPolicy,
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
412pub struct BrowserStudySearchResponse {
413 pub protocol_version: ProtocolVersion,
414 pub datastore_id: crate::PublicUuid,
415 pub family: String,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub predicates: Option<PredicateSet<StudyPredicate>>,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub query: Option<BrowserStudySearchQuery>,
420 #[serde(default)]
421 pub studies: Vec<BrowserStudySearchRecord>,
422 pub returned_count: u16,
423 pub requested_limit: u16,
424 pub ordering: BrowserSearchOrdering,
425 pub has_more: bool,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub next_cursor: Option<String>,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub empty_state: Option<BrowserStudySearchEmptyState>,
430}
431
432pub const MAX_BROWSER_STUDY_MATCH_EVIDENCE: usize = 5;
433
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436pub struct BrowserStudySearchRecord {
437 #[serde(flatten)]
438 pub discovery: BrowserStudyDiscoveryRecord,
439 #[serde(default)]
440 pub match_evidence: Vec<BrowserStudyMatchEvidence>,
441 pub match_evidence_truncated: bool,
442}
443
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(rename_all = "snake_case")]
446pub enum BrowserStudyMatchedField {
447 Name,
448 ExternalId,
449 Description,
450 Documentation,
451 VersionLabel,
452 VersionNote,
453 Doi,
454 Format,
455 Tag,
456 Domain,
457}
458
459#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
460#[serde(tag = "kind", rename_all = "snake_case")]
461pub enum BrowserStudyMatchEvidence {
462 Study {
463 study: StudyRef,
464 #[serde(default)]
465 matched_fields: Vec<BrowserStudyMatchedField>,
466 },
467 Dataset {
468 asset: AssetRef,
469 dataset: DatasetRef,
470 version: VersionRef,
471 name: String,
472 #[serde(default)]
473 matched_fields: Vec<BrowserStudyMatchedField>,
474 },
475 Datafile {
476 asset: AssetRef,
477 datafile: DataFileRef,
478 version: VersionRef,
479 name: String,
480 #[serde(default)]
481 matched_fields: Vec<BrowserStudyMatchedField>,
482 },
483}
484
485#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
486pub struct BrowserSearchOrdering {
487 pub id: String,
488 pub description: String,
489}
490
491#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
492pub struct BrowserStudySearchEmptyState {
493 pub code: String,
494 pub message: String,
495}
496
497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
498pub struct BrowserStudySearchCursorQuery {
499 pub datastore_id: crate::PublicUuid,
500 pub family: String,
501 #[serde(default, skip_serializing_if = "Option::is_none")]
502 pub predicates: Option<PredicateSet<StudyPredicate>>,
503 #[serde(default, skip_serializing_if = "Option::is_none")]
504 pub query: Option<BrowserStudySearchQuery>,
505}
506
507#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
508pub struct BrowserStudySearchCursor {
509 pub binding: SearchCursorBinding<BrowserStudySearchCursorQuery>,
510 pub after: StudySearchCursorAnchor,
511}
512
513#[allow(clippy::result_large_err)]
514impl BrowserStudySearchInput {
515 pub fn validate(
516 &mut self,
517 datastore_id: crate::PublicUuid,
518 protocol_version: &ProtocolVersion,
519 ) -> Result<(), ProtocolError> {
520 let mut issues = Vec::new();
521 if self.session.is_some() {
522 issues.push(ValidationIssue::new(
523 "body.session",
524 ValidationIssueCode::Unsupported,
525 "browser Study search does not accept daemon session state",
526 ));
527 }
528 match (&mut self.predicates, &mut self.query) {
529 (Some(predicates), None) => {
530 collect_predicate_set_issues("body.predicates", predicates, &mut issues);
531 collect_study_predicate_issues(predicates, &mut issues);
532 }
533 (None, Some(_)) => self.normalize_query(&mut issues),
534 (Some(_), Some(_)) => issues.push(ValidationIssue::new(
535 "body",
536 ValidationIssueCode::Conflict,
537 "browser Study search accepts either predicates or query, not both",
538 )),
539 (None, None) => issues.push(ValidationIssue::new(
540 "body.query",
541 ValidationIssueCode::Required,
542 "browser Study search requires predicates or query",
543 )),
544 }
545 collect_page_issues("body.page.limit", self.page.limit, &mut issues);
546 if issues.is_empty()
547 && let Err(error) = self.decoded_cursor(datastore_id, protocol_version)
548 {
549 issues.extend(error.issues);
550 }
551 if issues.is_empty() {
552 Ok(())
553 } else {
554 Err(ProtocolError::validation(
555 "browser study search request is invalid",
556 issues,
557 ))
558 }
559 }
560
561 pub fn decoded_cursor(
562 &self,
563 datastore_id: crate::PublicUuid,
564 protocol_version: &ProtocolVersion,
565 ) -> Result<Option<StudySearchCursorAnchor>, ProtocolError> {
566 let Some(raw_cursor) = self.page.cursor.as_deref() else {
567 return Ok(None);
568 };
569 let cursor: BrowserStudySearchCursor = serde_json::from_str(raw_cursor).map_err(|_| {
570 ProtocolError::validation(
571 "browser study search request is invalid",
572 vec![ValidationIssue::new(
573 "body.page.cursor",
574 ValidationIssueCode::InvalidFormat,
575 "browser study search cursor is invalid",
576 )],
577 )
578 })?;
579 if cursor.binding != self.cursor_binding(datastore_id, protocol_version) {
580 return Err(ProtocolError::validation(
581 "browser study search request is invalid",
582 vec![ValidationIssue::new(
583 "body.page.cursor",
584 ValidationIssueCode::Conflict,
585 "browser study search cursor does not match the datastore, search family, normalized query, page limit, or protocol version",
586 )],
587 ));
588 }
589 Ok(Some(cursor.after))
590 }
591
592 pub fn encode_cursor(
593 &self,
594 datastore_id: crate::PublicUuid,
595 protocol_version: &ProtocolVersion,
596 after: StudySearchCursorAnchor,
597 ) -> Result<String, ProtocolError> {
598 serde_json::to_string(&BrowserStudySearchCursor {
599 binding: self.cursor_binding(datastore_id, protocol_version),
600 after,
601 })
602 .map_err(|_| {
603 ProtocolError::new(
604 crate::public_error::ProtocolErrorCode::Internal,
605 "failed to encode browser study search cursor",
606 )
607 })
608 }
609
610 fn cursor_binding(
611 &self,
612 datastore_id: crate::PublicUuid,
613 protocol_version: &ProtocolVersion,
614 ) -> SearchCursorBinding<BrowserStudySearchCursorQuery> {
615 SearchCursorBinding {
616 query: BrowserStudySearchCursorQuery {
617 datastore_id,
618 family: "studies".to_string(),
619 predicates: self.predicates.clone(),
620 query: self.query.clone(),
621 },
622 page_limit: self.page.limit,
623 protocol_version: protocol_version.to_string(),
624 }
625 }
626
627 fn normalize_query(&mut self, issues: &mut Vec<ValidationIssue>) {
628 let Some(query) = self.query.as_mut() else {
629 return;
630 };
631 if let Some(text) = query.text.as_mut() {
632 text.value = text.value.trim().to_string();
633 if text.value.is_empty() {
634 issues.push(ValidationIssue::new(
635 "body.query.text.value",
636 ValidationIssueCode::TooShort,
637 "browser Study search text must not be empty",
638 ));
639 } else if text.value.len() > crate::search::MAX_TEXT_VALUE_LENGTH {
640 issues.push(ValidationIssue::new(
641 "body.query.text.value",
642 ValidationIssueCode::TooLong,
643 format!(
644 "browser Study search text must be at most {} characters",
645 crate::search::MAX_TEXT_VALUE_LENGTH
646 ),
647 ));
648 }
649 }
650
651 query.facets.any_tags.sort_by_key(tag_selector_key);
652 query
653 .facets
654 .any_tags
655 .dedup_by(|left, right| tag_selector_key(left) == tag_selector_key(right));
656 query.facets.any_domains.sort_by_key(domain_selector_key);
657 query
658 .facets
659 .any_domains
660 .dedup_by(|left, right| domain_selector_key(left) == domain_selector_key(right));
661
662 let term_count = usize::from(query.text.is_some())
663 + query.facets.any_tags.len()
664 + query.facets.any_domains.len();
665 if term_count == 0 {
666 issues.push(ValidationIssue::new(
667 "body.query",
668 ValidationIssueCode::TooFewItems,
669 "browser Study search query must include text, a tag, or a Domain",
670 ));
671 } else if term_count > crate::search::MAX_SEARCH_PREDICATES {
672 issues.push(ValidationIssue::new(
673 "body.query",
674 ValidationIssueCode::TooManyItems,
675 format!(
676 "browser Study search query must contain at most {} terms",
677 crate::search::MAX_SEARCH_PREDICATES
678 ),
679 ));
680 }
681 }
682}
683
684fn tag_selector_key(selector: &TagSelector) -> String {
685 match selector {
686 TagSelector::Id { tag } => format!("id:{}:{:?}:{}", tag.datastore_id, tag.kind, tag.id),
687 TagSelector::Label { scope, label } => format!("label:{scope:?}:{}", label.as_str()),
688 }
689}
690
691fn domain_selector_key(selector: &DomainSelector) -> String {
692 match selector {
693 DomainSelector::Id { domain } => {
694 format!("id:{}:{:?}:{}", domain.datastore_id, domain.kind, domain.id)
695 }
696 DomainSelector::Name { name } => format!("name:{}", name.as_str()),
697 }
698}
699
700#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
701pub struct BrowserContentSummaryPolicy {
702 pub included: bool,
703 pub reason: String,
704}
705
706#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
707#[serde(tag = "kind", rename_all = "snake_case")]
708pub enum StudyPredicate {
709 Text(TextPredicate<StudyTextField>),
710 Domain { domain: DomainSelector },
711 Tag(TagPredicate),
712}
713
714#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
715#[serde(rename_all = "snake_case")]
716pub enum StudyTextField {
717 Name,
718 Title,
719 Description,
720}
721
722#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
723pub struct StudyRegistrationPayload {
724 pub study: StudySummary,
725 pub domains: Vec<DomainSummary>,
726}
727
728#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
729pub struct StudyAccessGrantSummary {
730 pub study: StudyRef,
731 pub study_name: String,
732 pub user: String,
733 #[serde(default, skip_serializing_if = "Option::is_none")]
734 pub granted_by: Option<String>,
735}
736
737#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
738pub struct StudyCustodianSummary {
739 pub study: StudyRef,
740 pub study_name: String,
741 pub user: String,
742 pub primary: bool,
743 #[serde(default, skip_serializing_if = "Option::is_none")]
744 pub granted_by: Option<String>,
745}
746
747#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
748pub struct StudyDuoRestrictionSummary {
749 pub study: StudyRef,
750 pub study_name: String,
751 pub duo_id: String,
752 #[serde(default, skip_serializing_if = "Option::is_none")]
753 pub qualifier_ontology_namespace: Option<String>,
754 #[serde(default, skip_serializing_if = "Option::is_none")]
755 pub qualifier_ontology_id: Option<String>,
756 #[serde(default, skip_serializing_if = "Option::is_none")]
757 pub qualifier_text: Option<String>,
758 #[serde(default, skip_serializing_if = "Option::is_none")]
759 pub qualifier_date: Option<chrono::NaiveDate>,
760 #[serde(default, skip_serializing_if = "Option::is_none")]
761 pub qualifier_integer: Option<i32>,
762}
763
764#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
765pub struct StudyListResponse {
766 pub session: SessionStatusPayload,
767 pub studies: Vec<StudyRegistrationPayload>,
768}
769
770#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
771pub struct StudySearchResponse {
772 pub session: SessionStatusPayload,
773 pub studies: Vec<StudySummary>,
774 #[serde(default, skip_serializing_if = "Option::is_none")]
775 pub next_cursor: Option<String>,
776}
777
778#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
779pub struct StudyGetResponse {
780 pub session: SessionStatusPayload,
781 pub registration: StudyRegistrationPayload,
782}
783
784#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
785pub struct StudyAddResponse {
786 pub session: SessionStatusPayload,
787 pub registration: StudyRegistrationPayload,
788}
789
790#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
791pub struct StudyAddDomainResponse {
792 pub session: SessionStatusPayload,
793 pub registration: StudyRegistrationPayload,
794}
795
796#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
797pub struct StudyAccessListResponse {
798 pub session: SessionStatusPayload,
799 pub study: StudyRef,
800 pub study_name: String,
801 pub grants: Vec<StudyAccessGrantSummary>,
802}
803
804#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
805pub struct StudyAccessGrantResponse {
806 pub session: SessionStatusPayload,
807 pub study: StudyRef,
808 pub study_name: String,
809 pub grant: StudyAccessGrantSummary,
810}
811
812#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
813pub struct StudyAccessRevokeResponse {
814 pub session: SessionStatusPayload,
815 pub study: StudyRef,
816 pub study_name: String,
817 pub target_user: String,
818 pub revoked: bool,
819}
820
821#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
822pub struct StudyCustodiansListResponse {
823 pub session: SessionStatusPayload,
824 pub study: StudyRef,
825 pub study_name: String,
826 pub custodians: Vec<StudyCustodianSummary>,
827}
828
829#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
830pub struct StudyCustodianMutationResponse {
831 pub session: SessionStatusPayload,
832 pub study: StudyRef,
833 pub study_name: String,
834 #[serde(default, skip_serializing_if = "Option::is_none")]
835 pub custodian: Option<StudyCustodianSummary>,
836 pub target_user: String,
837 pub removed: bool,
838}
839
840#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
841pub struct StudyDuoListResponse {
842 pub session: SessionStatusPayload,
843 pub study: StudyRef,
844 pub study_name: String,
845 pub restrictions: Vec<StudyDuoRestrictionSummary>,
846}
847
848#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
849pub struct StudyDuoReplaceResponse {
850 pub session: SessionStatusPayload,
851 pub study: StudyRef,
852 pub study_name: String,
853 pub restrictions: Vec<StudyDuoRestrictionSummary>,
854}
855
856fn collect_predicate_set_issues(
857 path: &str,
858 predicates: &PredicateSet<StudyPredicate>,
859 issues: &mut Vec<ValidationIssue>,
860) {
861 let count = predicates.predicates().len();
862 if count == 0 {
863 issues.push(ValidationIssue::new(
864 path,
865 ValidationIssueCode::TooFewItems,
866 "study search predicates must not be empty",
867 ));
868 } else if count > crate::search::MAX_SEARCH_PREDICATES {
869 issues.push(ValidationIssue::new(
870 path,
871 ValidationIssueCode::TooManyItems,
872 format!(
873 "study search predicates must contain at most {} items",
874 crate::search::MAX_SEARCH_PREDICATES
875 ),
876 ));
877 }
878}
879
880fn collect_study_predicate_issues(
881 predicates: &mut PredicateSet<StudyPredicate>,
882 issues: &mut Vec<ValidationIssue>,
883) {
884 for (index, predicate) in predicates.predicates_mut().iter_mut().enumerate() {
885 if let StudyPredicate::Text(text) = predicate
886 && let Err(message) = text.validate()
887 {
888 let code = if text.value.trim().is_empty() {
889 ValidationIssueCode::TooShort
890 } else {
891 ValidationIssueCode::TooLong
892 };
893 issues.push(ValidationIssue::new(
894 format!("body.predicates.predicates[{index}].value"),
895 code,
896 message,
897 ));
898 }
899 }
900}
901
902fn collect_page_issues(path: &str, limit: u16, issues: &mut Vec<ValidationIssue>) {
903 if limit == 0 || limit > crate::search::MAX_PAGE_LIMIT {
904 issues.push(ValidationIssue::new(
905 path,
906 ValidationIssueCode::OutOfRange,
907 if limit == 0 {
908 "page limit must be greater than zero".to_string()
909 } else {
910 format!(
911 "page limit must be at most {}",
912 crate::search::MAX_PAGE_LIMIT
913 )
914 },
915 ));
916 }
917}
918
919fn collect_cursor_issues(
920 request: &SearchStudiesRequest,
921 protocol_version: &ProtocolVersion,
922 issues: &mut Vec<ValidationIssue>,
923) {
924 if let Some(raw_cursor) = request.page.cursor.as_deref() {
925 let cursor: StudySearchCursor = match serde_json::from_str(raw_cursor) {
926 Ok(cursor) => cursor,
927 Err(error) => {
928 issues.push(ValidationIssue::new(
929 "body.page.cursor",
930 ValidationIssueCode::InvalidFormat,
931 format!("study search cursor is invalid: {error}"),
932 ));
933 return;
934 }
935 };
936
937 if cursor.binding != request.cursor_binding(protocol_version) {
938 issues.push(ValidationIssue::new(
939 "body.page.cursor",
940 ValidationIssueCode::Conflict,
941 "study search cursor does not match the request predicates, page limit, or protocol version",
942 ));
943 }
944 }
945}
946
947#[cfg(test)]
948mod tests {
949 use super::*;
950 use crate::PublicUuid;
951 use uuid::Uuid;
952
953 #[test]
954 fn asset_aware_browser_query_normalizes_nested_facets_before_cursor_binding() {
955 let datastore = PublicUuid::from_uuid(Uuid::from_u128(1));
956 let version = ProtocolVersion::current();
957 let mut request: BrowserStudySearchInput = serde_json::from_value(serde_json::json!({
958 "query": {
959 "text": {"mode": "contains", "value": " sentinel "},
960 "facets": {
961 "any_tags": [
962 {"kind": "label", "scope": "global", "label": "priority"},
963 {"kind": "label", "scope": "global", "label": "curated"},
964 {"kind": "label", "scope": "global", "label": "priority"}
965 ],
966 "any_domains": [
967 {"kind": "name", "name": "population"},
968 {"kind": "name", "name": "demography"},
969 {"kind": "name", "name": "population"}
970 ]
971 }
972 },
973 "page": {"limit": 2}
974 }))
975 .unwrap();
976
977 request.validate(datastore, &version).unwrap();
978 let query = request.query.unwrap();
979 assert_eq!(query.text.unwrap().value, "sentinel");
980 assert_eq!(query.facets.any_tags.len(), 2);
981 assert_eq!(query.facets.any_domains.len(), 2);
982 assert_eq!(
983 query.facets.any_tags[0],
984 TagSelector::Label {
985 scope: crate::refs::TagScope::Global,
986 label: PublicName::new("curated").unwrap()
987 }
988 );
989 assert_eq!(
990 query.facets.any_domains[0],
991 DomainSelector::Name {
992 name: PublicName::new("demography").unwrap()
993 }
994 );
995 }
996
997 #[test]
998 fn asset_aware_browser_cursor_rejects_query_datastore_limit_and_version_reuse() {
999 let datastore = PublicUuid::from_uuid(Uuid::from_u128(1));
1000 let other_datastore = PublicUuid::from_uuid(Uuid::from_u128(2));
1001 let version = ProtocolVersion::current();
1002 let mut request: BrowserStudySearchInput = serde_json::from_value(serde_json::json!({
1003 "query": {
1004 "text": {"mode": "contains", "value": "sentinel"},
1005 "facets": {"any_tags": [], "any_domains": []}
1006 },
1007 "page": {"limit": 2}
1008 }))
1009 .unwrap();
1010 request.validate(datastore, &version).unwrap();
1011 request.page.cursor = Some(
1012 request
1013 .encode_cursor(
1014 datastore,
1015 &version,
1016 StudySearchCursorAnchor {
1017 study: StudyRef {
1018 datastore_id: crate::PublicUuid::from_uuid(uuid::Uuid::from_u128(5)),
1019 kind: crate::refs::ObjectKind::Study,
1020 id: PublicUuid::from_uuid(Uuid::from_u128(3)),
1021 },
1022 name: "Sentinel".to_string(),
1023 },
1024 )
1025 .unwrap(),
1026 );
1027
1028 assert!(request.validate(datastore, &version).is_ok());
1029 assert!(request.clone().validate(other_datastore, &version).is_err());
1030
1031 let mut changed_query = request.clone();
1032 changed_query
1033 .query
1034 .as_mut()
1035 .unwrap()
1036 .text
1037 .as_mut()
1038 .unwrap()
1039 .value = "other".to_string();
1040 assert!(changed_query.validate(datastore, &version).is_err());
1041
1042 let mut changed_limit = request.clone();
1043 changed_limit.page.limit = 3;
1044 assert!(changed_limit.validate(datastore, &version).is_err());
1045 assert!(
1046 request
1047 .validate(datastore, &ProtocolVersion::parse("1.0.0").unwrap())
1048 .is_err()
1049 );
1050
1051 let raw_cursor = request.page.cursor.take().unwrap();
1052 let mut cursor: BrowserStudySearchCursor = serde_json::from_str(&raw_cursor).unwrap();
1053 cursor.binding.query.family = "datasets".to_string();
1054 request.page.cursor = Some(serde_json::to_string(&cursor).unwrap());
1055 assert!(request.validate(datastore, &version).is_err());
1056 }
1057
1058 #[test]
1059 fn browser_query_preserves_predicate_contract_and_rejects_daemon_session_state() {
1060 let datastore = PublicUuid::from_uuid(Uuid::from_u128(1));
1061 let version = ProtocolVersion::current();
1062 let mut legacy: BrowserStudySearchInput = serde_json::from_value(serde_json::json!({
1063 "predicates": {
1064 "kind": "all_of",
1065 "predicates": [{
1066 "kind": "text",
1067 "field": "name",
1068 "mode": "contains",
1069 "value": " sentinel "
1070 }]
1071 },
1072 "page": {"limit": 2}
1073 }))
1074 .unwrap();
1075 legacy.validate(datastore, &version).unwrap();
1076 let PredicateSet::AllOf(predicates) = legacy.predicates.unwrap() else {
1077 panic!("expected all-of predicates");
1078 };
1079 let StudyPredicate::Text(text) = &predicates[0] else {
1080 panic!("expected text predicate");
1081 };
1082 assert_eq!(text.value, "sentinel");
1083
1084 let mut request = serde_json::from_value::<BrowserStudySearchInput>(serde_json::json!({
1085 "session": {"name": "daemon"},
1086 "query": {
1087 "text": {"mode": "contains", "value": "sentinel"},
1088 "facets": {"any_tags": [], "any_domains": []}
1089 },
1090 "page": {"limit": 2}
1091 }))
1092 .unwrap();
1093
1094 assert!(request.validate(datastore, &version).is_err());
1095 }
1096}