use crate::error::{DoDError, DoDResult};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ObservationId(Uuid);
impl ObservationId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
}
impl Default for ObservationId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for ObservationId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ObservationType {
Metric(MetricType),
Anomaly(AnomalyType),
SLOBreach(String),
UserReport,
IntegrationTest,
PerformanceBenchmark,
SecurityAudit,
ComplianceCheck,
SystemState,
Custom(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MetricType {
Latency,
Throughput,
ErrorRate,
CpuUsage,
MemoryUsage,
DiskUsage,
NetworkLatency,
CacheHitRate,
Custom(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AnomalyType {
Drift,
Outlier,
CorrelationChange,
TrendChange,
Custom(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ObservationSchema {
version: String,
required_fields: Vec<String>,
field_types: BTreeMap<String, FieldType>,
}
impl ObservationSchema {
pub fn new(version: impl Into<String>) -> Self {
Self {
version: version.into(),
required_fields: Vec::new(),
field_types: BTreeMap::new(),
}
}
pub fn with_required_field(mut self, field: impl Into<String>, field_type: FieldType) -> Self {
let field_name = field.into();
self.required_fields.push(field_name.clone());
self.field_types.insert(field_name, field_type);
self
}
pub fn validate(&self, observation: &Observation) -> DoDResult<()> {
let data_obj = observation.data.as_object().ok_or_else(|| {
DoDError::ObservationValidation("observation data must be a JSON object".to_string())
})?;
for required in &self.required_fields {
if !data_obj.contains_key(required) {
return Err(DoDError::ObservationValidation(format!(
"missing required field: {}",
required
)));
}
}
for (field, expected_type) in &self.field_types {
if let Some(value) = data_obj.get(field) {
if !expected_type.matches(value) {
return Err(DoDError::ObservationValidation(format!(
"field '{}' type mismatch: expected {}, got {}",
field,
expected_type.name(),
value_type_name(value)
)));
}
}
}
if observation.data.to_string().len() > crate::constants::MAX_OBSERVATION_SIZE {
return Err(DoDError::Observation(format!(
"observation exceeds maximum size of {} bytes",
crate::constants::MAX_OBSERVATION_SIZE
)));
}
Ok(())
}
pub fn version(&self) -> &str {
&self.version
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FieldType {
String,
Number,
Integer,
Boolean,
Object,
Array,
Enum(Vec<String>),
Optional(Box<FieldType>),
}
impl FieldType {
fn matches(&self, value: &serde_json::Value) -> bool {
match self {
FieldType::String => value.is_string(),
FieldType::Number => value.is_number(),
FieldType::Integer => value.is_i64() || value.is_u64(),
FieldType::Boolean => value.is_boolean(),
FieldType::Object => value.is_object(),
FieldType::Array => value.is_array(),
FieldType::Enum(variants) => {
if let Some(s) = value.as_str() {
variants.contains(&s.to_string())
} else {
false
}
}
FieldType::Optional(inner) => value.is_null() || inner.matches(value),
}
}
fn name(&self) -> &'static str {
match self {
FieldType::String => "string",
FieldType::Number => "number",
FieldType::Integer => "integer",
FieldType::Boolean => "boolean",
FieldType::Object => "object",
FieldType::Array => "array",
FieldType::Enum(_) => "enum",
FieldType::Optional(_) => "optional",
}
}
}
fn value_type_name(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::String(_) => "string",
serde_json::Value::Number(_) => "number",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Object(_) => "object",
serde_json::Value::Array(_) => "array",
serde_json::Value::Null => "null",
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Observation {
id: ObservationId,
obs_type: ObservationType,
data: serde_json::Value,
timestamp: DateTime<Utc>,
source: String,
schema_version: String,
tenant_id: String,
signature: Option<String>,
}
impl Observation {
pub fn new(
obs_type: ObservationType, data: serde_json::Value, source: impl Into<String>,
schema_version: impl Into<String>, tenant_id: impl Into<String>,
) -> DoDResult<Self> {
let obs = Self {
id: ObservationId::new(),
obs_type,
data,
timestamp: Utc::now(),
source: source.into(),
schema_version: schema_version.into(),
tenant_id: tenant_id.into(),
signature: None,
};
Ok(obs)
}
pub fn id(&self) -> ObservationId {
self.id
}
pub fn obs_type(&self) -> &ObservationType {
&self.obs_type
}
pub fn data(&self) -> &serde_json::Value {
&self.data
}
pub fn timestamp(&self) -> DateTime<Utc> {
self.timestamp
}
pub fn source(&self) -> &str {
&self.source
}
pub fn schema_version(&self) -> &str {
&self.schema_version
}
pub fn tenant_id(&self) -> &str {
&self.tenant_id
}
pub fn signature(&self) -> Option<&str> {
self.signature.as_deref()
}
pub fn with_signature(mut self, key: &[u8]) -> Self {
use hmac::Mac;
let mut mac =
hmac::Hmac::<sha2::Sha256>::new_from_slice(key).expect("HMAC key length is valid");
let payload = format!(
"{}{}{}{}{}",
self.id, self.schema_version, self.source, self.tenant_id, self.data
);
mac.update(payload.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
self.signature = Some(signature);
self
}
pub fn verify_signature(&self, key: &[u8]) -> DoDResult<bool> {
let sig = self
.signature
.as_ref()
.ok_or_else(|| DoDError::Receipt("observation has no signature".to_string()))?;
use hmac::Mac;
let mut mac =
hmac::Hmac::<sha2::Sha256>::new_from_slice(key).expect("HMAC key length is valid");
let payload = format!(
"{}{}{}{}{}",
self.id, self.schema_version, self.source, self.tenant_id, self.data
);
mac.update(payload.as_bytes());
let expected_sig = hex::encode(mac.finalize().into_bytes());
Ok(sig == &expected_sig)
}
}
impl fmt::Display for Observation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Observation(id={}, type={:?}, source={}, tenant={})",
self.id, self.obs_type, self.source, self.tenant_id
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[allow(clippy::expect_used)]
#[test]
fn test_observation_creation() {
let obs = Observation::new(
ObservationType::Metric(MetricType::Latency),
json!({"value": 42}),
"test-source",
"1.0",
"tenant-1",
)
.expect("observation creation");
assert_eq!(obs.source(), "test-source");
assert_eq!(obs.tenant_id(), "tenant-1");
assert_eq!(obs.schema_version(), "1.0");
}
#[allow(clippy::expect_used)]
#[test]
fn test_observation_signature() {
let key = b"test-key";
let obs = Observation::new(
ObservationType::Metric(MetricType::Throughput),
json!({"value": 100}),
"test-source",
"1.0",
"tenant-1",
)
.expect("observation creation")
.with_signature(key);
assert!(obs.signature.is_some());
let valid = obs.verify_signature(key).expect("verify");
assert!(valid);
}
#[allow(clippy::expect_used)]
#[test]
fn test_schema_validation() {
let schema = ObservationSchema::new("1.0").with_required_field("value", FieldType::Number);
let obs = Observation::new(
ObservationType::Metric(MetricType::Latency),
json!({"value": 42}),
"test",
"1.0",
"tenant-1",
)
.expect("observation");
assert!(schema.validate(&obs).is_ok());
}
#[allow(clippy::expect_used)]
#[test]
fn test_schema_validation_missing_field() {
let schema =
ObservationSchema::new("1.0").with_required_field("required_field", FieldType::String);
let obs = Observation::new(
ObservationType::Metric(MetricType::Latency),
json!({"other_field": "value"}),
"test",
"1.0",
"tenant-1",
)
.expect("observation");
assert!(schema.validate(&obs).is_err());
}
#[allow(clippy::expect_used)]
#[test]
fn test_field_type_validation() {
let schema = ObservationSchema::new("1.0").with_required_field("value", FieldType::Integer);
let obs = Observation::new(
ObservationType::Metric(MetricType::Latency),
json!({"value": "not a number"}),
"test",
"1.0",
"tenant-1",
)
.expect("observation");
assert!(schema.validate(&obs).is_err());
}
}