use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceRegisterBody {
pub consumer_kind: Value,
pub display_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hpke_public_key: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceHeartbeatBody {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vault_seq: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ext: Option<Value>,
}
pub const EXT_DEVICE_NAME: &str = "org.openvtc.device-name";
#[must_use]
pub fn device_name_ext(display_name: &str) -> Value {
serde_json::json!({ EXT_DEVICE_NAME: { "displayName": display_name } })
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceDisableBody {
pub device_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceWipeBody {
pub device_id: String,
pub scope: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WakeHandle {
pub gateway: String,
pub handle: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceSetWakeBody {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wake_handle: Option<WakeHandle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggested_triggers: Option<Vec<String>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_heartbeat_is_an_empty_object() {
assert_eq!(
serde_json::to_value(DeviceHeartbeatBody::default()).expect("serialises"),
serde_json::json!({})
);
}
#[test]
fn a_named_heartbeat_carries_the_device_name_extension() {
let body = DeviceHeartbeatBody {
platform: None,
vault_seq: None,
ext: Some(device_name_ext("OpenVTC on new-host (default)")),
};
assert_eq!(
serde_json::to_value(&body).expect("serialises"),
serde_json::json!({
"ext": {
"org.openvtc.device-name": {
"displayName": "OpenVTC on new-host (default)"
}
}
})
);
}
#[test]
fn the_extension_key_matches_the_schema_pattern() {
let segments: Vec<&str> = EXT_DEVICE_NAME.split('.').collect();
assert!(segments.len() >= 2, "{EXT_DEVICE_NAME} needs a namespace");
assert!(
EXT_DEVICE_NAME.starts_with(|c: char| c.is_ascii_lowercase()),
"{EXT_DEVICE_NAME} must start with a lowercase letter"
);
for segment in segments {
assert!(
!segment.is_empty(),
"{EXT_DEVICE_NAME} has an empty segment"
);
assert!(
segment
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
"{segment} may only hold [a-z0-9-]"
);
}
}
#[test]
fn an_unset_register_member_is_absent() {
let minimal = DeviceRegisterBody {
consumer_kind: serde_json::json!({"kind": "companion", "formFactor": "desktop"}),
display_name: "laptop".into(),
platform: None,
hpke_public_key: None,
};
assert_eq!(
serde_json::to_value(&minimal).expect("serialises"),
serde_json::json!({
"consumerKind": {"kind": "companion", "formFactor": "desktop"},
"displayName": "laptop",
})
);
}
#[test]
fn set_members_serialise_camel_case() {
let full = DeviceRegisterBody {
consumer_kind: serde_json::json!({"kind": "service", "serviceKind": "ai-agent"}),
display_name: "agent".into(),
platform: Some("macos".into()),
hpke_public_key: Some("zHpke".into()),
};
let v = serde_json::to_value(&full).expect("serialises");
assert_eq!(v.get("platform").and_then(Value::as_str), Some("macos"));
assert_eq!(
v.get("hpkePublicKey").and_then(Value::as_str),
Some("zHpke")
);
}
#[test]
fn a_wake_handle_nests_under_its_camel_case_member() {
let body = DeviceSetWakeBody {
wake_handle: Some(WakeHandle {
gateway: "apns".into(),
handle: "opaque".into(),
}),
suggested_triggers: Some(vec!["message".into()]),
};
assert_eq!(
serde_json::to_value(&body).expect("serialises"),
serde_json::json!({
"wakeHandle": {"gateway": "apns", "handle": "opaque"},
"suggestedTriggers": ["message"],
})
);
}
}