ahri_tre_protocol/
acquisition.rs1use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5#[serde(deny_unknown_fields)]
6pub struct AcquisitionRequest {
7 pub request: crate::ProtocolRequestEnvelope,
8 pub path: ahri_tre_types::AcquisitionPath,
9 pub endpoint: AcquisitionEndpoint,
10 pub authentication: ahri_tre_types::SourceAuthentication,
11 #[serde(default)]
14 pub source_sha256: Option<String>,
15 pub filename: String,
16 pub media_type: String,
17 #[serde(default)]
18 pub shared_source: Option<String>,
19 #[serde(default)]
20 pub budgets: ahri_tre_types::DisclosureBudgetOverrides,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct AcquisitionReservation {
26 pub id: uuid::Uuid,
27 pub deadline: chrono::DateTime<chrono::Utc>,
28 pub budgets: ahri_tre_types::DisclosureBudgets,
29}
30
31impl AcquisitionRequest {
32 pub fn session(&self) -> Option<crate::refs::SessionRef> {
33 use crate::{refs::SessionSelector, request::ProtocolRequest};
34 let selected = match &self.request.request {
35 ProtocolRequest::IngestFile(value) => value.session.as_ref(),
36 ProtocolRequest::IngestDatasetFile(value) => value.session.as_ref(),
37 ProtocolRequest::IngestRedcap(value) => value.session.as_ref(),
38 ProtocolRequest::IngestSql(value) => value.session.as_ref(),
39 _ => None,
40 };
41 match selected {
42 Some(SessionSelector::Reference { reference }) => Some(*reference),
43 _ => None,
44 }
45 }
46 pub fn valid(&self) -> bool {
47 use crate::{
48 ingest::{IngestDatasetFileSource, IngestFileSource},
49 request::ProtocolRequest,
50 };
51 if let ProtocolRequest::IngestSql(value) = &self.request.request {
52 use crate::ingest::{SqlEngine, SqlSource};
53 use ahri_tre_types::{AcquisitionPath, SourceAuthentication};
54 let source_matches = match &value.source {
55 SqlSource::Duckdb { path, .. } => {
56 self.path == AcquisitionPath::Client
57 && !path.is_empty()
58 && self.endpoint.as_str() == "client://duckdb"
59 }
60 SqlSource::Sqlite { path, .. } => {
61 self.path == AcquisitionPath::Client
62 && !path.is_empty()
63 && self.endpoint.as_str() == "client://sqlite"
64 }
65 SqlSource::Stream {
66 engine: SqlEngine::Duckdb,
67 } => {
68 self.path == AcquisitionPath::Client
69 && self.endpoint.as_str() == "client://duckdb"
70 }
71 SqlSource::Stream {
72 engine: SqlEngine::Sqlite,
73 } => {
74 self.path == AcquisitionPath::Client
75 && self.endpoint.as_str() == "client://sqlite"
76 }
77 SqlSource::Remote { endpoint } => {
78 endpoint == &self.endpoint
79 && matches!(
80 self.authentication,
81 SourceAuthentication::PostgreSql | SourceAuthentication::MsSql
82 )
83 }
84 };
85 let authentication_matches = match self.authentication {
86 SourceAuthentication::None => self.endpoint.as_str().starts_with("client://"),
87 SourceAuthentication::PostgreSql => {
88 self.endpoint.as_str().starts_with("postgresql://")
89 }
90 SourceAuthentication::MsSql => self.endpoint.as_str().starts_with("mssql://"),
91 _ => false,
92 };
93 return self.session().is_some()
94 && self
95 .request
96 .protocol_version
97 .ensure_supported(&crate::ProtocolVersion::current())
98 .is_ok()
99 && source_matches
100 && authentication_matches
101 && self.source_sha256.is_none()
102 && self.filename == "source.parquet"
103 && self.media_type == "application/vnd.apache.parquet";
104 }
105 if !self.endpoint.as_str().starts_with("https://") {
106 return false;
107 }
108 if let ProtocolRequest::IngestRedcap(value) = &self.request.request {
109 let options = &value.materialization;
110 return self.session().is_some()
111 && self
112 .request
113 .protocol_version
114 .ensure_supported(&crate::ProtocolVersion::current())
115 .is_ok()
116 && matches!(&value.source, crate::ingest::RedcapIngestSource::LiveApi { endpoint } if endpoint.as_str() == self.endpoint.as_str())
117 && self.authentication == ahri_tre_types::SourceAuthentication::Redcap
118 && self.source_sha256.is_none()
119 && self.shared_source.is_none()
120 && self.filename == "redcap.bundle"
121 && self.media_type == "application/vnd.ahri-tre.redcap"
122 && (options.forms.is_empty() || options.skip_forms.is_empty())
123 && matches!(
124 options.response_encoding.as_deref(),
125 None | Some("utf8" | "iso88592")
126 )
127 && (1..=5).contains(&options.max_attempts.unwrap_or(5))
128 && (1..=120).contains(&options.timeout_secs.unwrap_or(120));
129 }
130 let format = match &self.request.request {
131 ProtocolRequest::IngestFile(value)
132 if matches!(value.source, IngestFileSource::Stream)
133 && !value.new_version
134 && !value.bump_major
135 && !value.bump_minor =>
136 {
137 Some(value.format.as_str())
138 }
139 ProtocolRequest::IngestDatasetFile(value)
140 if matches!(value.source, IngestDatasetFileSource::Stream)
141 && !value.materialization.replace
142 && value.materialization.new_version.is_none() =>
143 {
144 value.parse.format.as_deref()
145 }
146 _ => None,
147 };
148 self.session().is_some()
149 && match self.authentication {
150 ahri_tre_types::SourceAuthentication::SignedUrl => {
151 self.source_sha256.as_ref().is_some_and(|digest| {
152 digest.len() == 64
153 && digest
154 .bytes()
155 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
156 }) && url::Url::parse(self.endpoint.as_str()).is_ok_and(|url| url.path() == "/")
157 }
158 _ => self.source_sha256.is_none(),
159 }
160 && self
161 .request
162 .protocol_version
163 .ensure_supported(&crate::ProtocolVersion::current())
164 .is_ok()
165 && format
166 .and_then(|format| {
167 crate::upload::upload_format(&self.filename, format, &self.media_type)
168 })
169 .is_some()
170 && matches!(
171 self.authentication,
172 ahri_tre_types::SourceAuthentication::None
173 | ahri_tre_types::SourceAuthentication::Basic
174 | ahri_tre_types::SourceAuthentication::Bearer
175 | ahri_tre_types::SourceAuthentication::SignedUrl
176 )
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
182#[serde(transparent)]
183pub struct AcquisitionEndpoint(String);
184impl AcquisitionEndpoint {
185 pub fn as_str(&self) -> &str {
186 &self.0
187 }
188}
189impl From<HttpsEndpoint> for AcquisitionEndpoint {
190 fn from(value: HttpsEndpoint) -> Self {
191 Self(value.0)
192 }
193}
194impl std::str::FromStr for AcquisitionEndpoint {
195 type Err = &'static str;
196 fn from_str(value: &str) -> Result<Self, Self::Err> {
197 serde_json::from_value(serde_json::Value::String(value.into()))
198 .map_err(|_| "Invalid public source endpoint")
199 }
200}
201impl<'de> Deserialize<'de> for AcquisitionEndpoint {
202 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
203 let value = String::deserialize(deserializer)?;
204 if matches!(value.as_str(), "client://duckdb" | "client://sqlite") {
205 return Ok(Self(value));
206 }
207 let url = url::Url::parse(&value).ok().filter(|url| {
208 value.len() <= 4096 && url.host_str().is_some()
209 && url.username().is_empty() && url.password().is_none()
210 && url.query().is_none() && url.fragment().is_none()
211 && (url.scheme() == "https" || (matches!(url.scheme(), "postgresql" | "mssql")
212 && url.port().is_some_and(|port| port != 0)
213 && url.path().strip_prefix('/').is_some_and(|database| !database.is_empty() && !database.contains('/') && database.len() <= 255)))
214 }).ok_or_else(|| serde::de::Error::custom("A public source endpoint without credentials or connection options is required"))?;
215 Ok(Self(url.into()))
216 }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
220#[serde(transparent)]
221pub struct HttpsEndpoint(String);
222
223impl HttpsEndpoint {
224 pub fn as_str(&self) -> &str {
225 &self.0
226 }
227}
228impl<'de> Deserialize<'de> for HttpsEndpoint {
229 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
230 let value = String::deserialize(deserializer)?;
231 let valid = url::Url::parse(&value).ok().is_some_and(|url| {
232 value.len() <= 4096
233 && url.scheme() == "https"
234 && url.host_str().is_some()
235 && url.username().is_empty()
236 && url.password().is_none()
237 && url.query().is_none()
238 && url.fragment().is_none()
239 });
240 if !valid {
241 return Err(serde::de::Error::custom(
242 "A public HTTPS endpoint without credentials, query or fragment is required",
243 ));
244 }
245 Ok(Self(
247 url::Url::parse(&value)
248 .map_err(|_| serde::de::Error::custom("Invalid HTTPS endpoint"))?
249 .into(),
250 ))
251 }
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(deny_unknown_fields)]
257pub struct RedcapUploadRoles {
258 pub project_info: crate::upload::UploadMetadata,
259 pub metadata: crate::upload::UploadMetadata,
260 pub records_eav: crate::upload::UploadMetadata,
261}
262impl From<RedcapUploadRoles> for crate::ingest::RedcapIngestSource {
263 fn from(roles: RedcapUploadRoles) -> Self {
264 Self::Stream {
265 project_info: roles.project_info,
266 metadata: roles.metadata,
267 records_eav: roles.records_eav,
268 }
269 }
270}
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct AcquisitionUploadMetadata {
273 #[serde(flatten)]
274 pub upload: crate::upload::UploadMetadata,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub redcap: Option<RedcapUploadRoles>,
277}