mod canonical;
mod shape;
use assay_evidence::types::EvidenceEvent;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub use canonical::{canonical_bytes, canonical_json_bytes, digest_canonical_json};
pub use shape::validate_json_shape;
pub type AdapterResult<T> = Result<T, AdapterError>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtocolDescriptor {
pub name: String,
pub spec_version: String,
pub schema_id: Option<String>,
pub spec_url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AdapterDescriptor {
pub adapter_id: &'static str,
pub adapter_version: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AdapterCapabilities {
pub supported_event_types: Vec<String>,
pub supported_spec_versions: Vec<String>,
pub supports_strict: bool,
pub supports_lenient: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConvertMode {
#[default]
Strict,
Lenient,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ConvertOptions {
pub mode: ConvertMode,
pub max_payload_bytes: Option<u64>,
pub max_json_depth: Option<u64>,
pub max_array_length: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AdapterInput<'a> {
pub payload: &'a [u8],
pub media_type: &'a str,
pub protocol_version: Option<&'a str>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RawPayloadRef {
pub sha256: String,
pub size_bytes: u64,
pub media_type: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum LossinessLevel {
#[default]
None,
Low,
High,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct LossinessReport {
pub lossiness_level: LossinessLevel,
pub unmapped_fields_count: u32,
pub raw_payload_ref: Option<RawPayloadRef>,
pub notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct AdapterBatch {
pub events: Vec<EvidenceEvent>,
pub lossiness: LossinessReport,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AdapterErrorKind {
Config,
Measurement,
Infrastructure,
UnsupportedProtocolVersion,
StrictLossinessViolation,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
#[error("{kind:?}: {message}")]
pub struct AdapterError {
pub kind: AdapterErrorKind,
pub message: String,
}
impl AdapterError {
#[must_use]
pub fn new(kind: AdapterErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
}
pub trait AttachmentWriter {
fn write_raw_payload(&self, payload: &[u8], media_type: &str) -> AdapterResult<RawPayloadRef>;
}
pub trait ProtocolAdapter {
fn adapter(&self) -> AdapterDescriptor;
fn protocol(&self) -> ProtocolDescriptor;
fn capabilities(&self) -> AdapterCapabilities;
fn convert(
&self,
input: AdapterInput<'_>,
options: &ConvertOptions,
attachments: &dyn AttachmentWriter,
) -> AdapterResult<AdapterBatch>;
}
#[cfg(test)]
mod tests {
use super::*;
use assay_evidence::types::EvidenceEvent;
struct StubWriter;
impl AttachmentWriter for StubWriter {
fn write_raw_payload(
&self,
payload: &[u8],
media_type: &str,
) -> AdapterResult<RawPayloadRef> {
Ok(RawPayloadRef {
sha256: format!("sha256:{}", payload.len()),
size_bytes: payload.len() as u64,
media_type: media_type.to_string(),
})
}
}
struct StubAdapter;
impl ProtocolAdapter for StubAdapter {
fn adapter(&self) -> AdapterDescriptor {
AdapterDescriptor {
adapter_id: "assay-adapter-acp",
adapter_version: env!("CARGO_PKG_VERSION"),
}
}
fn protocol(&self) -> ProtocolDescriptor {
ProtocolDescriptor {
name: "acp".to_string(),
spec_version: "2.11.0".to_string(),
schema_id: Some("acp.packet".to_string()),
spec_url: Some("https://example.invalid/acp".to_string()),
}
}
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities {
supported_event_types: vec!["assay.adapter.acp.packet".to_string()],
supported_spec_versions: vec![">=2.11 <3.0".to_string()],
supports_strict: true,
supports_lenient: true,
}
}
fn convert(
&self,
input: AdapterInput<'_>,
options: &ConvertOptions,
attachments: &dyn AttachmentWriter,
) -> AdapterResult<AdapterBatch> {
if matches!(options.mode, ConvertMode::Strict) && input.payload.is_empty() {
return Err(AdapterError::new(
AdapterErrorKind::Measurement,
"empty payload in strict mode",
));
}
let raw_ref = attachments.write_raw_payload(input.payload, input.media_type)?;
let event = EvidenceEvent::new(
"assay.adapter.acp.packet",
"urn:assay:adapter:acp",
"run-1",
0,
serde_json::json!({"media_type": input.media_type}),
);
Ok(AdapterBatch {
events: vec![event],
lossiness: LossinessReport {
lossiness_level: LossinessLevel::None,
unmapped_fields_count: 0,
raw_payload_ref: Some(raw_ref),
notes: Vec::new(),
},
})
}
}
#[test]
fn strict_empty_payload_fails() {
let adapter = StubAdapter;
let writer = StubWriter;
let input = AdapterInput {
payload: &[],
media_type: "application/json",
protocol_version: Some("2.11.0"),
};
let err = adapter
.convert(input, &ConvertOptions::default(), &writer)
.expect_err("strict empty payload should fail");
assert_eq!(err.kind, AdapterErrorKind::Measurement);
}
#[test]
fn lenient_path_emits_event_and_raw_ref() {
let adapter = StubAdapter;
let writer = StubWriter;
let input = AdapterInput {
payload: br#"{"kind":"checkout"}"#,
media_type: "application/json",
protocol_version: Some("2.11.0"),
};
let batch = adapter
.convert(
input,
&ConvertOptions {
mode: ConvertMode::Lenient,
max_payload_bytes: Some(4096),
max_json_depth: None,
max_array_length: None,
},
&writer,
)
.expect("lenient conversion should succeed");
assert_eq!(batch.events.len(), 1);
assert_eq!(batch.lossiness.lossiness_level, LossinessLevel::None);
assert_eq!(
batch.lossiness.raw_payload_ref.expect("raw ref").size_bytes,
19
);
}
#[test]
fn adapter_descriptor_exposes_identity() {
let adapter = StubAdapter;
let descriptor = adapter.adapter();
assert_eq!(descriptor.adapter_id, "assay-adapter-acp");
assert!(!descriptor.adapter_version.is_empty());
}
}