1mod canonical;
5mod shape;
6
7use assay_evidence::types::EvidenceEvent;
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11pub use canonical::{canonical_bytes, canonical_json_bytes, digest_canonical_json};
12pub use shape::validate_json_shape;
13
14pub type AdapterResult<T> = Result<T, AdapterError>;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct ProtocolDescriptor {
20 pub name: String,
22 pub spec_version: String,
24 pub schema_id: Option<String>,
26 pub spec_url: Option<String>,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct AdapterDescriptor {
33 pub adapter_id: &'static str,
35 pub adapter_version: &'static str,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
41pub struct AdapterCapabilities {
42 pub supported_event_types: Vec<String>,
44 pub supported_spec_versions: Vec<String>,
46 pub supports_strict: bool,
48 pub supports_lenient: bool,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
54#[serde(rename_all = "snake_case")]
55pub enum ConvertMode {
56 #[default]
58 Strict,
59 Lenient,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
65pub struct ConvertOptions {
66 pub mode: ConvertMode,
68 pub max_payload_bytes: Option<u64>,
70 pub max_json_depth: Option<u64>,
72 pub max_array_length: Option<u64>,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct AdapterInput<'a> {
79 pub payload: &'a [u8],
81 pub media_type: &'a str,
83 pub protocol_version: Option<&'a str>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct RawPayloadRef {
90 pub sha256: String,
92 pub size_bytes: u64,
94 pub media_type: String,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
100#[serde(rename_all = "snake_case")]
101pub enum LossinessLevel {
102 #[default]
104 None,
105 Low,
107 High,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
113pub struct LossinessReport {
114 pub lossiness_level: LossinessLevel,
116 pub unmapped_fields_count: u32,
118 pub raw_payload_ref: Option<RawPayloadRef>,
120 pub notes: Vec<String>,
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
126pub struct AdapterBatch {
127 pub events: Vec<EvidenceEvent>,
129 pub lossiness: LossinessReport,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136#[non_exhaustive]
137pub enum AdapterErrorKind {
138 Config,
140 Measurement,
142 Infrastructure,
144 UnsupportedProtocolVersion,
146 StrictLossinessViolation,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
152#[error("{kind:?}: {message}")]
153pub struct AdapterError {
154 pub kind: AdapterErrorKind,
156 pub message: String,
158}
159
160impl AdapterError {
161 #[must_use]
163 pub fn new(kind: AdapterErrorKind, message: impl Into<String>) -> Self {
164 Self {
165 kind,
166 message: message.into(),
167 }
168 }
169}
170
171pub trait AttachmentWriter {
173 fn write_raw_payload(&self, payload: &[u8], media_type: &str) -> AdapterResult<RawPayloadRef>;
175}
176
177pub trait ProtocolAdapter {
179 fn adapter(&self) -> AdapterDescriptor;
181
182 fn protocol(&self) -> ProtocolDescriptor;
184
185 fn capabilities(&self) -> AdapterCapabilities;
187
188 fn convert(
190 &self,
191 input: AdapterInput<'_>,
192 options: &ConvertOptions,
193 attachments: &dyn AttachmentWriter,
194 ) -> AdapterResult<AdapterBatch>;
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use assay_evidence::types::EvidenceEvent;
201
202 struct StubWriter;
203
204 impl AttachmentWriter for StubWriter {
205 fn write_raw_payload(
206 &self,
207 payload: &[u8],
208 media_type: &str,
209 ) -> AdapterResult<RawPayloadRef> {
210 Ok(RawPayloadRef {
211 sha256: format!("sha256:{}", payload.len()),
212 size_bytes: payload.len() as u64,
213 media_type: media_type.to_string(),
214 })
215 }
216 }
217
218 struct StubAdapter;
219
220 impl ProtocolAdapter for StubAdapter {
221 fn adapter(&self) -> AdapterDescriptor {
222 AdapterDescriptor {
223 adapter_id: "assay-adapter-acp",
224 adapter_version: env!("CARGO_PKG_VERSION"),
225 }
226 }
227
228 fn protocol(&self) -> ProtocolDescriptor {
229 ProtocolDescriptor {
230 name: "acp".to_string(),
231 spec_version: "2.11.0".to_string(),
232 schema_id: Some("acp.packet".to_string()),
233 spec_url: Some("https://example.invalid/acp".to_string()),
234 }
235 }
236
237 fn capabilities(&self) -> AdapterCapabilities {
238 AdapterCapabilities {
239 supported_event_types: vec!["assay.adapter.acp.packet".to_string()],
240 supported_spec_versions: vec![">=2.11 <3.0".to_string()],
241 supports_strict: true,
242 supports_lenient: true,
243 }
244 }
245
246 fn convert(
247 &self,
248 input: AdapterInput<'_>,
249 options: &ConvertOptions,
250 attachments: &dyn AttachmentWriter,
251 ) -> AdapterResult<AdapterBatch> {
252 if matches!(options.mode, ConvertMode::Strict) && input.payload.is_empty() {
253 return Err(AdapterError::new(
254 AdapterErrorKind::Measurement,
255 "empty payload in strict mode",
256 ));
257 }
258
259 let raw_ref = attachments.write_raw_payload(input.payload, input.media_type)?;
260 let event = EvidenceEvent::new(
261 "assay.adapter.acp.packet",
262 "urn:assay:adapter:acp",
263 "run-1",
264 0,
265 serde_json::json!({"media_type": input.media_type}),
266 );
267
268 Ok(AdapterBatch {
269 events: vec![event],
270 lossiness: LossinessReport {
271 lossiness_level: LossinessLevel::None,
272 unmapped_fields_count: 0,
273 raw_payload_ref: Some(raw_ref),
274 notes: Vec::new(),
275 },
276 })
277 }
278 }
279
280 #[test]
281 fn strict_empty_payload_fails() {
282 let adapter = StubAdapter;
283 let writer = StubWriter;
284 let input = AdapterInput {
285 payload: &[],
286 media_type: "application/json",
287 protocol_version: Some("2.11.0"),
288 };
289 let err = adapter
290 .convert(input, &ConvertOptions::default(), &writer)
291 .expect_err("strict empty payload should fail");
292 assert_eq!(err.kind, AdapterErrorKind::Measurement);
293 }
294
295 #[test]
296 fn lenient_path_emits_event_and_raw_ref() {
297 let adapter = StubAdapter;
298 let writer = StubWriter;
299 let input = AdapterInput {
300 payload: br#"{"kind":"checkout"}"#,
301 media_type: "application/json",
302 protocol_version: Some("2.11.0"),
303 };
304 let batch = adapter
305 .convert(
306 input,
307 &ConvertOptions {
308 mode: ConvertMode::Lenient,
309 max_payload_bytes: Some(4096),
310 max_json_depth: None,
311 max_array_length: None,
312 },
313 &writer,
314 )
315 .expect("lenient conversion should succeed");
316 assert_eq!(batch.events.len(), 1);
317 assert_eq!(batch.lossiness.lossiness_level, LossinessLevel::None);
318 assert_eq!(
319 batch.lossiness.raw_payload_ref.expect("raw ref").size_bytes,
320 19
321 );
322 }
323
324 #[test]
325 fn adapter_descriptor_exposes_identity() {
326 let adapter = StubAdapter;
327 let descriptor = adapter.adapter();
328
329 assert_eq!(descriptor.adapter_id, "assay-adapter-acp");
330 assert!(!descriptor.adapter_version.is_empty());
331 }
332}