use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::VtaError;
pub const TSP_SERVICE_TYPE: &str = "TSPTransport";
pub const DIDCOMM_SERVICE_TYPE: &str = "DIDCommMessaging";
pub const REST_SERVICE_TYPE: &str = "VTARest";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {
Tsp,
Didcomm,
Rest,
}
impl Protocol {
pub const PREFERENCE_ORDER: [Protocol; 3] = [Protocol::Tsp, Protocol::Didcomm, Protocol::Rest];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Protocol::Tsp => "tsp",
Protocol::Didcomm => "didcomm",
Protocol::Rest => "rest",
}
}
}
impl std::fmt::Display for Protocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ServiceCapabilities {
pub tsp: Option<String>,
pub didcomm: Option<String>,
pub rest: Option<String>,
}
impl ServiceCapabilities {
#[must_use]
pub fn from_did_document(doc: &Value) -> Self {
let mut caps = ServiceCapabilities::default();
let Some(services) = doc.get("service").and_then(Value::as_array) else {
return caps;
};
for svc in services {
let Some(uri) = svc.get("serviceEndpoint").and_then(endpoint_uri) else {
continue;
};
if uri.is_empty() {
continue;
}
if service_has_type(svc, TSP_SERVICE_TYPE) {
caps.tsp.get_or_insert(uri);
} else if service_has_type(svc, DIDCOMM_SERVICE_TYPE) {
caps.didcomm.get_or_insert(uri);
} else if service_has_type(svc, REST_SERVICE_TYPE) {
caps.rest.get_or_insert(uri);
}
}
caps
}
#[must_use]
pub fn endpoint(&self, protocol: Protocol) -> Option<&str> {
match protocol {
Protocol::Tsp => self.tsp.as_deref(),
Protocol::Didcomm => self.didcomm.as_deref(),
Protocol::Rest => self.rest.as_deref(),
}
}
#[must_use]
pub fn advertised(&self) -> Vec<Protocol> {
Protocol::PREFERENCE_ORDER
.into_iter()
.filter(|p| self.endpoint(*p).is_some())
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProtocolMatch {
pub protocol: Protocol,
pub peer_endpoint: String,
}
pub fn select_protocol(
ours: &ServiceCapabilities,
theirs: &ServiceCapabilities,
counterparty_did: &str,
) -> Result<ProtocolMatch, VtaError> {
for protocol in Protocol::PREFERENCE_ORDER {
if ours.endpoint(protocol).is_some()
&& let Some(peer) = theirs.endpoint(protocol)
{
return Ok(ProtocolMatch {
protocol,
peer_endpoint: peer.to_string(),
});
}
}
Err(VtaError::NoMatchingProtocol {
counterparty_did: counterparty_did.to_string(),
ours: ours.advertised(),
theirs: theirs.advertised(),
})
}
fn service_has_type(svc: &Value, type_: &str) -> bool {
match svc.get("type") {
Some(Value::String(s)) => s == type_,
Some(Value::Array(arr)) => arr.iter().any(|t| t.as_str() == Some(type_)),
_ => false,
}
}
fn endpoint_uri(endpoint: &Value) -> Option<String> {
match endpoint {
Value::String(s) => Some(s.clone()),
Value::Object(map) => map.get("uri")?.as_str().map(str::to_string),
Value::Array(arr) => arr.iter().find_map(endpoint_uri),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn doc(services: Value) -> Value {
json!({ "id": "did:webvh:peer", "service": services })
}
#[test]
fn protocol_preference_order_is_tsp_didcomm_rest() {
assert_eq!(
Protocol::PREFERENCE_ORDER,
[Protocol::Tsp, Protocol::Didcomm, Protocol::Rest]
);
assert!(Protocol::Tsp < Protocol::Didcomm);
assert!(Protocol::Didcomm < Protocol::Rest);
}
#[test]
fn parses_each_type_and_endpoint_shape() {
let caps = ServiceCapabilities::from_did_document(&doc(json!([
{ "id": "did:webvh:peer#tsp", "type": "TSPTransport",
"serviceEndpoint": "did:webvh:med-tsp" },
{ "id": "did:webvh:peer#vta-didcomm", "type": "DIDCommMessaging",
"serviceEndpoint": [{ "accept": ["didcomm/v2"], "uri": "did:webvh:med-dc" }] },
{ "id": "did:webvh:peer#vta-rest", "type": "VTARest",
"serviceEndpoint": "https://peer.example/" },
])));
assert_eq!(caps.tsp.as_deref(), Some("did:webvh:med-tsp"));
assert_eq!(caps.didcomm.as_deref(), Some("did:webvh:med-dc"));
assert_eq!(caps.rest.as_deref(), Some("https://peer.example/"));
assert_eq!(
caps.advertised(),
vec![Protocol::Tsp, Protocol::Didcomm, Protocol::Rest]
);
}
#[test]
fn matches_by_type_not_id() {
let caps = ServiceCapabilities::from_did_document(&doc(json!([
{ "id": "did:webvh:peer#tsp-transport", "type": "TSPTransport",
"serviceEndpoint": "did:webvh:med" },
{ "id": "did:webvh:peer#tsp", "type": "SomethingElse",
"serviceEndpoint": "https://decoy.example/" },
])));
assert_eq!(caps.tsp.as_deref(), Some("did:webvh:med"));
assert_eq!(caps.rest, None);
assert_eq!(caps.didcomm, None);
}
#[test]
fn type_may_be_an_array() {
let caps = ServiceCapabilities::from_did_document(&doc(json!([
{ "id": "x", "type": ["DIDCommMessaging", "OtherThing"],
"serviceEndpoint": { "uri": "did:webvh:med" } },
])));
assert_eq!(caps.didcomm.as_deref(), Some("did:webvh:med"));
}
#[test]
fn empty_or_missing_service_array_is_no_capabilities() {
assert_eq!(
ServiceCapabilities::from_did_document(&json!({ "id": "did:x" })),
ServiceCapabilities::default()
);
assert!(
ServiceCapabilities::from_did_document(&doc(json!([])))
.advertised()
.is_empty()
);
}
fn caps(tsp: Option<&str>, didcomm: Option<&str>, rest: Option<&str>) -> ServiceCapabilities {
ServiceCapabilities {
tsp: tsp.map(str::to_string),
didcomm: didcomm.map(str::to_string),
rest: rest.map(str::to_string),
}
}
#[test]
fn select_prefers_tsp_when_both_advertise_it() {
let ours = caps(
Some("did:m:ours"),
Some("did:dc:ours"),
Some("https://ours"),
);
let theirs = caps(
Some("did:m:theirs"),
Some("did:dc:theirs"),
Some("https://theirs"),
);
let m = select_protocol(&ours, &theirs, "did:webvh:peer").unwrap();
assert_eq!(m.protocol, Protocol::Tsp);
assert_eq!(m.peer_endpoint, "did:m:theirs");
}
#[test]
fn select_falls_through_to_didcomm_then_rest() {
let ours = caps(None, Some("did:dc:ours"), Some("https://ours"));
let theirs = caps(Some("did:m:theirs"), Some("did:dc:theirs"), None);
let m = select_protocol(&ours, &theirs, "did:webvh:peer").unwrap();
assert_eq!(m.protocol, Protocol::Didcomm);
assert_eq!(m.peer_endpoint, "did:dc:theirs");
let ours = caps(Some("did:m:ours"), None, Some("https://ours"));
let theirs = caps(None, Some("did:dc:theirs"), Some("https://theirs"));
let m = select_protocol(&ours, &theirs, "did:webvh:peer").unwrap();
assert_eq!(m.protocol, Protocol::Rest);
assert_eq!(m.peer_endpoint, "https://theirs");
}
#[test]
fn select_requires_both_sides_to_advertise() {
let ours = caps(Some("did:m:ours"), None, None);
let theirs = caps(None, None, Some("https://theirs"));
let err = select_protocol(&ours, &theirs, "did:webvh:peer").unwrap_err();
match err {
VtaError::NoMatchingProtocol {
counterparty_did,
ours,
theirs,
} => {
assert_eq!(counterparty_did, "did:webvh:peer");
assert_eq!(ours, vec![Protocol::Tsp]);
assert_eq!(theirs, vec![Protocol::Rest]);
}
other => panic!("expected NoMatchingProtocol, got {other:?}"),
}
}
}