use serde_json::Value;
use crate::error::TrqlError;
use crate::transport::TransportKind;
pub const TSP_SERVICE_TYPE: &str = "TSPTransport";
pub const DIDCOMM_SERVICE_TYPE: &str = "DIDCommMessaging";
pub const REST_SERVICE_TYPE: &str = "TRQPRest";
pub const VTA_REST_SERVICE_TYPE: &str = "VTARest";
pub const REST_SERVICE_TYPES: [&str; 2] = [REST_SERVICE_TYPE, VTA_REST_SERVICE_TYPE];
pub const TRUST_REGISTRY_SERVICE_TYPE: &str = "TrustRegistry";
pub const PREFERENCE_ORDER: [TransportKind; 3] = [
TransportKind::Tsp,
TransportKind::Didcomm,
TransportKind::Https,
];
impl TransportKind {
#[must_use]
pub fn service_type(self) -> &'static str {
match self {
Self::Tsp => TSP_SERVICE_TYPE,
Self::Didcomm => DIDCOMM_SERVICE_TYPE,
Self::Https => REST_SERVICE_TYPE,
}
}
#[must_use]
pub fn is_compiled(self) -> bool {
match self {
Self::Tsp => cfg!(feature = "tsp"),
Self::Didcomm => cfg!(feature = "didcomm"),
Self::Https => cfg!(feature = "https"),
}
}
#[must_use]
pub fn compiled() -> Vec<TransportKind> {
PREFERENCE_ORDER
.into_iter()
.filter(|k| k.is_compiled())
.collect()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ServiceCapabilities {
pub tsp: Option<String>,
pub didcomm: Option<String>,
pub https: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransportChoice {
pub kind: TransportKind,
pub endpoint: String,
}
impl ServiceCapabilities {
#[must_use]
pub fn from_document(doc: &Value) -> Self {
let mut caps = Self::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 REST_SERVICE_TYPES.iter().any(|t| service_has_type(svc, t)) {
caps.https.get_or_insert(uri);
}
}
caps
}
#[must_use]
pub fn endpoint(&self, kind: TransportKind) -> Option<&str> {
match kind {
TransportKind::Tsp => self.tsp.as_deref(),
TransportKind::Didcomm => self.didcomm.as_deref(),
TransportKind::Https => self.https.as_deref(),
}
}
#[must_use]
pub fn advertised(&self) -> Vec<TransportKind> {
PREFERENCE_ORDER
.into_iter()
.filter(|k| self.endpoint(*k).is_some())
.collect()
}
pub fn select(&self, ours: &[TransportKind]) -> Result<TransportChoice, TrqlError> {
for kind in PREFERENCE_ORDER {
if ours.contains(&kind)
&& let Some(endpoint) = self.endpoint(kind)
{
return Ok(TransportChoice {
kind,
endpoint: endpoint.to_string(),
});
}
}
Err(TrqlError::NoMatchingTransport {
ours: ours.to_vec(),
theirs: self.advertised(),
})
}
}
#[must_use]
pub fn registry_referral(doc: &Value) -> Option<String> {
let own_id = doc.get("id").and_then(Value::as_str);
doc.get("service")
.and_then(Value::as_array)?
.iter()
.filter(|svc| service_has_type(svc, TRUST_REGISTRY_SERVICE_TYPE))
.filter_map(|svc| svc.get("serviceEndpoint").and_then(endpoint_uri))
.find(|uri| uri.starts_with("did:") && Some(uri.as_str()) != own_id)
}
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:registry.example", "service": services })
}
const ALL: [TransportKind; 3] = [
TransportKind::Tsp,
TransportKind::Didcomm,
TransportKind::Https,
];
#[test]
fn a_vtc_pointing_at_a_registry_did_is_a_referral() {
let vtc = json!({
"id": "did:webvh:QmVtcScid:community.example",
"service": [
{ "id": "#trust-registry", "type": "TrustRegistry",
"serviceEndpoint": { "uri": "did:webvh:QmRegistryScid:registry.example",
"profile": "https://trustoverip.org/profiles/trqp/v2" } },
{ "id": "#didcomm", "type": "DIDCommMessaging",
"serviceEndpoint": { "uri": "did:web:mediator.example" } },
]
});
assert_eq!(
registry_referral(&vtc).as_deref(),
Some("did:webvh:QmRegistryScid:registry.example")
);
}
#[test]
fn a_registry_describing_its_own_surface_is_not_a_referral() {
let registry = json!({
"id": "did:webvh:QmRegistryScid:registry.example",
"service": [
{ "id": "#rest", "type": ["TRQPRest", "TrustRegistry"],
"serviceEndpoint": { "uri": "https://registry.example",
"profile": "https://trustoverip.org/profiles/trqp/v2" } },
]
});
assert_eq!(registry_referral(®istry), None);
}
#[test]
fn a_self_referential_entry_is_not_a_referral() {
let doc = json!({
"id": "did:webvh:QmRegistryScid:registry.example",
"service": [
{ "id": "#trust-registry", "type": "TrustRegistry",
"serviceEndpoint": "did:webvh:QmRegistryScid:registry.example" },
]
});
assert_eq!(registry_referral(&doc), None);
}
#[test]
fn a_referral_did_never_becomes_an_https_endpoint() {
let vtc = json!({
"id": "did:webvh:QmVtcScid:community.example",
"service": [
{ "id": "#trust-registry", "type": "TrustRegistry",
"serviceEndpoint": { "uri": "did:webvh:QmRegistryScid:registry.example" } },
]
});
let caps = ServiceCapabilities::from_document(&vtc);
assert_eq!(caps, ServiceCapabilities::default(), "{caps:?}");
assert!(
caps.select(&ALL).is_err(),
"a referral advertises no transport of its own"
);
}
#[test]
fn a_mediator_did_is_not_mistaken_for_a_referral() {
let doc = json!({
"id": "did:webvh:QmRegistryScid:registry.example",
"service": [
{ "id": "#tsp", "type": "TSPTransport", "serviceEndpoint": "did:web:mediator" },
{ "id": "#didcomm", "type": "DIDCommMessaging",
"serviceEndpoint": { "uri": "did:web:mediator" } },
]
});
assert_eq!(registry_referral(&doc), None);
}
#[test]
fn one_hop_lands_on_the_registrys_capabilities() {
let vtc = json!({
"id": "did:webvh:QmVtcScid:community.example",
"service": [{ "id": "#trust-registry", "type": "TrustRegistry",
"serviceEndpoint": { "uri": "did:webvh:QmRegistryScid:registry.example" } }]
});
let registry = json!({
"id": "did:webvh:QmRegistryScid:registry.example",
"service": [
{ "id": "#rest", "type": ["TRQPRest", "TrustRegistry"],
"serviceEndpoint": { "uri": "https://registry.example" } },
{ "id": "#tsp", "type": "TSPTransport", "serviceEndpoint": "did:web:mediator" },
]
});
let target = registry_referral(&vtc).expect("the VTC refers");
assert_eq!(target, registry["id"].as_str().unwrap());
assert_eq!(registry_referral(®istry), None);
let choice = ServiceCapabilities::from_document(®istry)
.select(&ALL)
.unwrap();
assert_eq!(choice.kind, TransportKind::Tsp);
assert_eq!(choice.endpoint, "did:web:mediator");
}
#[test]
fn a_document_with_no_services_refers_nowhere() {
assert_eq!(registry_referral(&json!({ "id": "did:webvh:x" })), None);
}
#[test]
fn parses_each_service_type() {
let caps = ServiceCapabilities::from_document(&doc(json!([
{ "id": "#tsp", "type": "TSPTransport", "serviceEndpoint": "did:web:mediator" },
{ "id": "#didcomm", "type": "DIDCommMessaging",
"serviceEndpoint": { "uri": "did:web:mediator", "accept": ["didcomm/v2"] } },
{ "id": "#rest", "type": "TRQPRest", "serviceEndpoint": "https://registry.example" },
])));
assert_eq!(caps.tsp.as_deref(), Some("did:web:mediator"));
assert_eq!(caps.didcomm.as_deref(), Some("did:web:mediator"));
assert_eq!(caps.https.as_deref(), Some("https://registry.example"));
}
#[test]
fn tolerates_string_object_and_array_endpoints() {
for endpoint in [
json!("did:web:mediator"),
json!({ "uri": "did:web:mediator", "accept": ["didcomm/v2"] }),
json!([{ "uri": "did:web:mediator" }]),
] {
let caps = ServiceCapabilities::from_document(&doc(json!([
{ "id": "#x", "type": "DIDCommMessaging", "serviceEndpoint": endpoint }
])));
assert_eq!(caps.didcomm.as_deref(), Some("did:web:mediator"));
}
}
#[test]
fn matches_on_type_not_fragment() {
let caps = ServiceCapabilities::from_document(&doc(json!([
{ "id": "did:x#tsp-transport", "type": "TSPTransport", "serviceEndpoint": "did:web:m" },
{ "id": "did:x#tsp", "type": "TRQPRest", "serviceEndpoint": "https://r.example" },
])));
assert_eq!(caps.tsp.as_deref(), Some("did:web:m"));
assert_eq!(caps.https.as_deref(), Some("https://r.example"));
}
#[test]
fn type_may_be_an_array() {
let caps = ServiceCapabilities::from_document(&doc(json!([
{ "id": "#m", "type": ["DIDCommMessaging", "Other"], "serviceEndpoint": "did:web:m" }
])));
assert_eq!(caps.didcomm.as_deref(), Some("did:web:m"));
}
#[test]
fn ignores_unknown_types_empty_and_missing_endpoints() {
let caps = ServiceCapabilities::from_document(&doc(json!([
{ "id": "#a", "type": "SomethingElse", "serviceEndpoint": "https://x" },
{ "id": "#b", "type": "TRQPRest", "serviceEndpoint": "" },
{ "id": "#c", "type": "TSPTransport" },
{ "id": "#d", "type": "DIDCommMessaging", "serviceEndpoint": 42 },
])));
assert_eq!(caps, ServiceCapabilities::default());
assert!(caps.advertised().is_empty());
}
#[test]
fn document_without_services_yields_nothing() {
assert_eq!(
ServiceCapabilities::from_document(&json!({ "id": "did:x" })),
ServiceCapabilities::default()
);
}
#[test]
fn first_entry_of_a_type_wins() {
let caps = ServiceCapabilities::from_document(&doc(json!([
{ "id": "#r1", "type": "TRQPRest", "serviceEndpoint": "https://first.example" },
{ "id": "#r2", "type": "TRQPRest", "serviceEndpoint": "https://second.example" },
])));
assert_eq!(caps.https.as_deref(), Some("https://first.example"));
}
#[test]
fn selects_the_most_preferred_shared_transport() {
let caps = ServiceCapabilities {
tsp: Some("did:web:m".into()),
didcomm: Some("did:web:m".into()),
https: Some("https://r.example".into()),
};
assert_eq!(caps.select(&ALL).unwrap().kind, TransportKind::Tsp);
let choice = caps
.select(&[TransportKind::Didcomm, TransportKind::Https])
.unwrap();
assert_eq!(choice.kind, TransportKind::Didcomm);
assert_eq!(choice.endpoint, "did:web:m");
let choice = caps.select(&[TransportKind::Https]).unwrap();
assert_eq!(choice.kind, TransportKind::Https);
assert_eq!(choice.endpoint, "https://r.example");
}
#[test]
fn no_shared_transport_is_a_typed_error_not_a_fallback() {
let caps = ServiceCapabilities {
didcomm: Some("did:web:m".into()),
..Default::default()
};
let err = caps.select(&[TransportKind::Https]).unwrap_err();
match err {
TrqlError::NoMatchingTransport { ours, theirs } => {
assert_eq!(ours, vec![TransportKind::Https]);
assert_eq!(theirs, vec![TransportKind::Didcomm]);
}
other => panic!("expected NoMatchingTransport, got {other:?}"),
}
}
#[test]
fn empty_capabilities_report_an_empty_peer_set() {
let err = ServiceCapabilities::default()
.select(&ALL)
.expect_err("no transports advertised");
match err {
TrqlError::NoMatchingTransport { theirs, .. } => assert!(theirs.is_empty()),
other => panic!("expected NoMatchingTransport, got {other:?}"),
}
}
#[test]
fn no_matching_transport_is_not_retryable() {
let err = ServiceCapabilities::default().select(&ALL).unwrap_err();
assert!(!err.is_retryable());
}
#[test]
fn service_types_match_the_workspace_constants() {
assert_eq!(TransportKind::Tsp.service_type(), "TSPTransport");
assert_eq!(TransportKind::Didcomm.service_type(), "DIDCommMessaging");
assert_eq!(TransportKind::Https.service_type(), "TRQPRest");
}
#[test]
fn both_rest_type_names_are_discovered() {
for ty in ["TRQPRest", "VTARest"] {
let caps = ServiceCapabilities::from_document(&doc(json!([
{ "id": "#rest", "type": ty, "serviceEndpoint": "https://r.example" }
])));
assert_eq!(
caps.https.as_deref(),
Some("https://r.example"),
"{ty} must be recognised as REST"
);
}
}
#[test]
fn compiled_transports_are_in_preference_order() {
let compiled = TransportKind::compiled();
let expected: Vec<_> = PREFERENCE_ORDER
.into_iter()
.filter(|k| compiled.contains(k))
.collect();
assert_eq!(compiled, expected);
#[cfg(feature = "https")]
assert!(compiled.contains(&TransportKind::Https));
}
}