1use crate::{
2 PublicName,
3 asset::{AssetCatalog, AssetSelector},
4 datafile::DataFileSummary,
5 dataset::RegisteredDatasetVariablePayload,
6 domain::DomainSelector,
7 refs::{DataFileRef, SessionSelector, StudyRef, VersionRef},
8 study::StudySelector,
9};
10use ahri_tre_types::{AssetType, AssetVersionRecord, DatasetRecord, TransformationId};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15pub struct IngestFileRequest {
16 pub classification: ahri_tre_types::IngestClassification,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub session: Option<SessionSelector>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub study: Option<StudySelector>,
21 pub asset: PublicName,
22 pub source: IngestFileSource,
23 pub format: String,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub description: Option<String>,
26 #[serde(default)]
27 pub new_version: bool,
28 #[serde(default)]
29 pub bump_major: bool,
30 #[serde(default)]
31 pub bump_minor: bool,
32 #[serde(default)]
33 pub compress: bool,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub encrypt: Option<bool>,
36 #[serde(default)]
37 pub hash_source_local: bool,
38 #[serde(default)]
39 pub verify_copy: bool,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct IngestDatasetFileRequest {
44 pub classification: ahri_tre_types::IngestClassification,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub session: Option<SessionSelector>,
47 pub study: StudySelector,
48 pub domain: DomainSelector,
49 pub dataset: PublicName,
50 pub source: IngestDatasetFileSource,
51 pub materialization: DatasetMaterializationOptions,
52 pub parse: DatasetFileParseOptions,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct DatasetCreateFromDatafileRequest {
58 #[serde(
59 default,
60 skip_serializing_if = "crate::mutation::MutationOptions::is_empty"
61 )]
62 pub mutation: crate::mutation::MutationOptions,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub session: Option<SessionSelector>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub study: Option<StudySelector>,
67 pub domain: DomainSelector,
68 pub dataset: PublicName,
69 pub source: ManagedDataFileSource,
70 pub materialization: DatasetMaterializationOptions,
71 pub parse: DatasetFileParseOptions,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct IngestSqlRequest {
76 pub classification: ahri_tre_types::IngestClassification,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub session: Option<SessionSelector>,
79 pub study: StudySelector,
80 pub domain: DomainSelector,
81 pub dataset: PublicName,
82 pub source: SqlSource,
83 pub sql: String,
84 pub materialization: SqlMaterializationOptions,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct IngestRedcapRequest {
89 pub classification: ahri_tre_types::IngestClassification,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub session: Option<SessionSelector>,
92 pub study: StudySelector,
93 pub domain: DomainSelector,
94 pub source: RedcapIngestSource,
95 pub materialization: RedcapMaterializationOptions,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct RedcapMaterializationOptions {
100 #[serde(default)]
101 pub no_register_dictionary: bool,
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub forms: Vec<String>,
104 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub skip_forms: Vec<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub dataset_prefix: Option<PublicName>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub description: Option<String>,
110 #[serde(default)]
111 pub force_metadata_updates: bool,
112 #[serde(default = "default_true")]
113 pub compress: bool,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub encrypt: Option<bool>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub response_encoding: Option<String>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub max_attempts: Option<u32>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub timeout_secs: Option<u64>,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(tag = "kind", rename_all = "snake_case")]
126pub enum RedcapIngestSource {
127 LiveApi {
129 endpoint: crate::acquisition::HttpsEndpoint,
130 },
131 Stream {
134 project_info: crate::upload::UploadMetadata,
135 metadata: crate::upload::UploadMetadata,
136 records_eav: crate::upload::UploadMetadata,
137 },
138 OfflineExport {
139 project_info_path: String,
140 metadata_path: String,
141 records_eav_path: String,
142 #[serde(default)]
143 request_only: bool,
144 },
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(tag = "kind", rename_all = "snake_case")]
149pub enum IngestDatasetFileSource {
150 Stream,
151 RestrictedLocalPath { path: String, request_only: bool },
152 HttpsUri { uri: IngestRemoteUri },
153}
154
155pub fn is_safe_csv_upload_filename(value: &str) -> bool {
160 !value.is_empty()
161 && value.len() <= 255
162 && value.ends_with(".csv")
163 && !value.starts_with('.')
164 && !value
165 .chars()
166 .any(|character| character.is_control() || matches!(character, '/' | '\\' | ':' | '\0'))
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct ManagedDataFileSource {
171 pub asset: AssetSelector,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub version: Option<String>,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct DatasetMaterializationOptions {
178 pub description: String,
179 #[serde(default)]
180 pub replace: bool,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub new_version: Option<String>,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186pub struct SqlMaterializationOptions {
187 pub description: String,
188 #[serde(default)]
189 pub replace: bool,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub new_version: Option<String>,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct DatasetFileParseOptions {
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub format: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub sheet: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub header: Option<bool>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub delimiter: Option<char>,
204 #[serde(default, skip_serializing_if = "Vec::is_empty")]
205 pub null_strings: Vec<String>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub json_format: Option<String>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub sample_rows: Option<usize>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub vocabulary_threshold: Option<usize>,
212 #[serde(default)]
213 pub force_metadata_updates: bool,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
218pub enum SqlSource {
219 Stream { engine: SqlEngine },
221 Remote {
222 endpoint: crate::acquisition::AcquisitionEndpoint,
223 },
224 Duckdb {
225 path: String,
226 #[serde(default)]
227 request_only: bool,
228 },
229 Sqlite {
230 path: String,
231 #[serde(default)]
232 request_only: bool,
233 },
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(rename_all = "snake_case")]
238pub enum SqlEngine {
239 Duckdb,
240 Sqlite,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
245pub enum IngestFileSource {
246 Stream,
247 RestrictedLocalPath {
248 path: String,
249 },
250 HttpsUri {
251 uri: IngestRemoteUri,
252 },
253 UploadedContent {
254 filename: String,
255 media_type: String,
256 content_length: u64,
257 digest: String,
258 content_base64: String,
259 },
260}
261
262#[derive(Clone, PartialEq, Eq)]
263pub struct IngestRemoteUri {
264 pub uri: String,
265 pub credential_bearing: bool,
266 pub request_only: bool,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270pub struct IngestFileResponse {
271 pub session: crate::session::SessionStatusPayload,
272 pub study: String,
273 pub asset: IngestedAssetSummary,
274 pub datafile: DataFileSummary,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub version: Option<crate::asset::SafeAssetVersionSummary>,
277 pub transformation_id: TransformationId,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub transformation: Option<crate::refs::TransformationRef>,
280 pub source_size_bytes: u64,
281 pub stored_size_bytes: u64,
282 pub source: IngestSourceSummary,
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286pub struct DatasetMaterializationResponse {
287 pub session: crate::session::SessionStatusPayload,
288 pub study: String,
289 pub catalog: AssetCatalog,
290 pub dataset: DatasetRecord,
291 pub lineage: crate::transformation::TransformationSummary,
292 pub row_count: usize,
293 pub column_count: usize,
294 pub variables: Vec<RegisteredDatasetVariablePayload>,
295 #[serde(default)]
296 pub metadata_warnings: Vec<String>,
297 pub source: DatasetMaterializationSourceSummary,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub source_catalog: Option<AssetCatalog>,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub source_version: Option<AssetVersionRecord>,
302 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub source_datafile: Option<DataFileSummary>,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307pub struct RedcapIngestResponse {
308 #[serde(default)]
309 pub form_outcomes: Vec<RedcapFormOutcome>,
310 pub session: crate::session::SessionStatusPayload,
311 pub study: String,
312 pub redcap_study: ahri_tre_types::StudyRecord,
313 #[serde(default)]
314 pub preserved_artifacts: Vec<RedcapPreservedArtifactSummary>,
315 pub ingest_lineage: crate::transformation::TransformationSummary,
316 #[serde(default)]
317 pub datasets: Vec<RedcapFormSummary>,
318 #[serde(default)]
319 pub warnings: Vec<RedcapIngestWarning>,
320 pub source: RedcapIngestSourceSummary,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct RedcapPreservedArtifactSummary {
325 pub catalog: crate::asset::SafeAssetCatalog,
326 pub datafile: DataFileSummary,
327 pub lineage: crate::transformation::TransformationSummary,
328 pub source_size_bytes: u64,
329 pub stored_size_bytes: u64,
330}
331
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333pub struct RedcapIngestSourceSummary {
334 pub kind: RedcapIngestSourceKind,
335 #[serde(default)]
336 pub request_only: bool,
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(rename_all = "snake_case")]
341pub enum RedcapIngestSourceKind {
342 OfflineExport,
343 LiveApi,
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
347pub struct RedcapIngestWarning {
348 pub kind: RedcapIngestWarningKind,
349 pub message: String,
350}
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354pub enum RedcapIngestWarningKind {
355 ExpectedNormalization,
356 TransportFallback,
357 SkippedMaterialization,
358 PartialMetadataLinkage,
359 Other,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363pub struct DatasetMaterializationSourceSummary {
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub upload: Option<UploadSummary>,
366 pub kind: DatasetMaterializationSourceKind,
367 #[serde(default)]
368 pub request_only: bool,
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
372#[serde(rename_all = "snake_case")]
373pub enum DatasetMaterializationSourceKind {
374 Https,
375 LocalDatasetFile,
376 ManagedDataFile,
377 SqlSource,
378 RedcapForm,
379}
380
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382pub struct IngestedAssetSummary {
383 pub name: String,
384 pub asset_type: AssetType,
385 pub study: StudyRef,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub description: Option<String>,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub version: Option<VersionRef>,
390}
391
392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
393pub struct IngestSourceSummary {
394 pub kind: IngestSourceKind,
395 #[serde(default)]
396 pub request_only: bool,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub upload: Option<UploadSummary>,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402pub struct UploadSummary {
403 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub acquisition: Option<ahri_tre_types::AcquisitionPath>,
405 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub budgets: Option<ahri_tre_types::DisclosureBudgets>,
407 pub filename: String,
408 pub media_type: String,
409 pub content_length: u64,
410 pub digest: String,
411 pub outcome: UploadOutcome,
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
415#[serde(rename_all = "snake_case")]
416pub enum UploadOutcome {
417 Ingested,
418}
419
420#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
421#[serde(rename_all = "snake_case")]
422pub enum IngestSourceKind {
423 RestrictedLocalPath,
424 HttpsUri,
425 UploadedContent,
426}
427
428impl IngestFileSource {
429 pub const fn source_kind(&self) -> IngestSourceKind {
430 match self {
431 Self::Stream => IngestSourceKind::UploadedContent,
432 Self::RestrictedLocalPath { .. } => IngestSourceKind::RestrictedLocalPath,
433 Self::HttpsUri { .. } => IngestSourceKind::HttpsUri,
434 Self::UploadedContent { .. } => IngestSourceKind::UploadedContent,
435 }
436 }
437
438 pub const fn request_only(&self) -> bool {
439 match self {
440 Self::RestrictedLocalPath { .. } => true,
441 Self::HttpsUri { uri } => uri.request_only || uri.credential_bearing,
442 Self::Stream | Self::UploadedContent { .. } => true,
443 }
444 }
445}
446
447impl std::fmt::Debug for IngestRemoteUri {
448 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449 f.write_str("IngestRemoteUri(<request-only>)")
450 }
451}
452impl Serialize for IngestRemoteUri {
453 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
454 let endpoint: crate::acquisition::HttpsEndpoint =
455 serde_json::from_value(serde_json::Value::String(self.uri.clone())).map_err(|_| {
456 serde::ser::Error::custom(
457 "Source credentials require the sensitive acquisition API",
458 )
459 })?;
460 use serde::ser::SerializeStruct;
461 let mut state = serializer.serialize_struct("IngestRemoteUri", 3)?;
462 state.serialize_field("uri", endpoint.as_str())?;
463 state.serialize_field("credential_bearing", &false)?;
464 state.serialize_field("request_only", &self.request_only)?;
465 state.end()
466 }
467}
468impl<'de> Deserialize<'de> for IngestRemoteUri {
469 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
470 #[derive(Deserialize)]
471 #[serde(deny_unknown_fields)]
472 struct PublicUri {
473 uri: crate::acquisition::HttpsEndpoint,
474 #[serde(default)]
475 credential_bearing: bool,
476 #[serde(default)]
477 request_only: bool,
478 }
479 let value = PublicUri::deserialize(deserializer)?;
480 if value.credential_bearing {
481 return Err(serde::de::Error::custom(
482 "Source credentials require the sensitive acquisition API",
483 ));
484 }
485 Ok(Self {
486 uri: value.uri.as_str().into(),
487 credential_bearing: false,
488 request_only: value.request_only,
489 })
490 }
491}
492
493impl IngestRemoteUri {
494 pub fn new(uri: impl Into<String>) -> Self {
495 let uri = uri.into();
496 let credential_bearing = remote_uri_is_credential_bearing(&uri);
497 Self {
498 credential_bearing,
499 request_only: credential_bearing,
500 uri,
501 }
502 }
503}
504
505impl RedcapIngestSource {
506 pub const fn source_kind(&self) -> RedcapIngestSourceKind {
507 match self {
508 Self::LiveApi { .. } => RedcapIngestSourceKind::LiveApi,
509 _ => RedcapIngestSourceKind::OfflineExport,
510 }
511 }
512
513 pub const fn request_only(&self) -> bool {
514 match self {
515 Self::Stream { .. } | Self::LiveApi { .. } => true,
516 Self::OfflineExport { request_only, .. } => *request_only,
517 }
518 }
519}
520
521pub fn classify_redcap_warning(message: &str) -> RedcapIngestWarningKind {
522 let lowered = message.to_ascii_lowercase();
523 if lowered.contains("normaliz") || lowered.contains("metadata") {
524 RedcapIngestWarningKind::ExpectedNormalization
525 } else if lowered.contains("fallback")
526 || lowered.contains("retry")
527 || lowered.contains("transport")
528 {
529 RedcapIngestWarningKind::TransportFallback
530 } else if lowered.contains("skip") || lowered.contains("not materializ") {
531 RedcapIngestWarningKind::SkippedMaterialization
532 } else if lowered.contains("link") || lowered.contains("partial") {
533 RedcapIngestWarningKind::PartialMetadataLinkage
534 } else {
535 RedcapIngestWarningKind::Other
536 }
537}
538
539fn remote_uri_is_credential_bearing(uri: &str) -> bool {
540 let Some(rest) = uri.strip_prefix("https://") else {
541 return false;
542 };
543 rest.split_once('/')
544 .map(|(authority, _)| authority.contains('@'))
545 .unwrap_or_else(|| rest.contains('@'))
546 || uri.contains('?')
547 || uri.contains('#')
548}
549
550const fn default_true() -> bool {
551 true
552}
553
554pub fn datafile_ref(
555 datastore_id: crate::PublicUuid,
556 datafile_id: ahri_tre_types::AssetId,
557) -> DataFileRef {
558 DataFileRef {
559 datastore_id,
560 kind: crate::refs::ObjectKind::Asset,
561 id: crate::PublicUuid::from_uuid(datafile_id.0),
562 }
563}
564
565pub fn version_ref(
566 datastore_id: crate::PublicUuid,
567 version_id: ahri_tre_types::VersionId,
568) -> VersionRef {
569 VersionRef {
570 datastore_id,
571 kind: crate::refs::ObjectKind::AssetVersion,
572 id: crate::PublicUuid::from_uuid(version_id.0),
573 }
574}
575
576impl IngestDatasetFileSource {
577 pub fn restricted_local_path(path: impl Into<String>) -> Self {
578 Self::RestrictedLocalPath {
579 path: path.into(),
580 request_only: true,
581 }
582 }
583
584 pub fn https_uri(uri: impl Into<String>) -> Self {
585 Self::HttpsUri {
586 uri: IngestRemoteUri::new(uri),
587 }
588 }
589
590 pub const fn source_kind(&self) -> IngestSourceKind {
591 match self {
592 Self::Stream => IngestSourceKind::UploadedContent,
593 Self::RestrictedLocalPath { .. } => IngestSourceKind::RestrictedLocalPath,
594 Self::HttpsUri { .. } => IngestSourceKind::HttpsUri,
595 }
596 }
597
598 pub const fn request_only(&self) -> bool {
599 match self {
600 Self::Stream => true,
601 Self::RestrictedLocalPath { request_only, .. } => *request_only,
602 Self::HttpsUri { uri } => uri.request_only || uri.credential_bearing,
603 }
604 }
605}
606
607impl SqlSource {
608 pub const fn request_only(&self) -> bool {
609 match self {
610 Self::Stream { .. } | Self::Remote { .. } => true,
611 Self::Duckdb { request_only, .. } | Self::Sqlite { request_only, .. } => *request_only,
612 }
613 }
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 #[test]
621 fn uploaded_csv_source_round_trips_explicit_bounded_content_metadata() {
622 let source = IngestFileSource::UploadedContent {
623 filename: "visits.csv".to_string(),
624 media_type: "text/csv".to_string(),
625 content_length: 18,
626 digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
627 .to_string(),
628 content_base64: "aWQsdmFsdWUKMSxvbmUK".to_string(),
629 };
630
631 let encoded = serde_json::to_value(&source).expect("upload source should serialize");
632 assert_eq!(encoded["kind"], "uploaded_content");
633 assert_eq!(encoded["filename"], "visits.csv");
634 assert_eq!(encoded["media_type"], "text/csv");
635 assert_eq!(encoded["content_length"], 18);
636 assert_eq!(
637 serde_json::from_value::<IngestFileSource>(encoded)
638 .expect("upload source should deserialize"),
639 source
640 );
641 assert_eq!(source.source_kind(), IngestSourceKind::UploadedContent);
642 assert!(source.request_only());
643 }
644}
645
646#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
648pub struct RedcapFormSummary {
649 pub catalog: crate::asset::SafeAssetCatalog,
650 pub lineage: crate::transformation::TransformationSummary,
651 pub source: DatasetMaterializationSourceSummary,
652}
653
654#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
656pub struct RedcapFormOutcome {
657 pub dataset: String,
658 pub outcome: RedcapFormOutcomeKind,
659}
660#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
661#[serde(rename_all = "snake_case")]
662pub enum RedcapFormOutcomeKind {
663 Complete,
664 NotMaterialized,
665 InspectionRequired,
666}