1use serde_json::Value;
36
37use crate::error::TrqlError;
38use crate::transport::TransportKind;
39
40pub const TSP_SERVICE_TYPE: &str = "TSPTransport";
47
48pub const DIDCOMM_SERVICE_TYPE: &str = "DIDCommMessaging";
50
51pub const REST_SERVICE_TYPE: &str = "TRQPRest";
56
57pub const VTA_REST_SERVICE_TYPE: &str = "VTARest";
64
65pub const REST_SERVICE_TYPES: [&str; 2] = [REST_SERVICE_TYPE, VTA_REST_SERVICE_TYPE];
70
71pub const PREFERENCE_ORDER: [TransportKind; 3] = [
76 TransportKind::Tsp,
77 TransportKind::Didcomm,
78 TransportKind::Https,
79];
80
81impl TransportKind {
82 #[must_use]
84 pub fn service_type(self) -> &'static str {
85 match self {
86 Self::Tsp => TSP_SERVICE_TYPE,
87 Self::Didcomm => DIDCOMM_SERVICE_TYPE,
88 Self::Https => REST_SERVICE_TYPE,
89 }
90 }
91
92 #[must_use]
94 pub fn is_compiled(self) -> bool {
95 match self {
96 Self::Tsp => cfg!(feature = "tsp"),
97 Self::Didcomm => cfg!(feature = "didcomm"),
98 Self::Https => cfg!(feature = "https"),
99 }
100 }
101
102 #[must_use]
108 pub fn compiled() -> Vec<TransportKind> {
109 PREFERENCE_ORDER
110 .into_iter()
111 .filter(|k| k.is_compiled())
112 .collect()
113 }
114}
115
116#[derive(Debug, Clone, Default, PartialEq, Eq)]
126pub struct ServiceCapabilities {
127 pub tsp: Option<String>,
129 pub didcomm: Option<String>,
131 pub https: Option<String>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct TransportChoice {
138 pub kind: TransportKind,
140 pub endpoint: String,
143}
144
145impl ServiceCapabilities {
146 #[must_use]
152 pub fn from_document(doc: &Value) -> Self {
153 let mut caps = Self::default();
154 let Some(services) = doc.get("service").and_then(Value::as_array) else {
155 return caps;
156 };
157 for svc in services {
158 let Some(uri) = svc.get("serviceEndpoint").and_then(endpoint_uri) else {
159 continue;
160 };
161 if uri.is_empty() {
162 continue;
163 }
164 if service_has_type(svc, TSP_SERVICE_TYPE) {
165 caps.tsp.get_or_insert(uri);
166 } else if service_has_type(svc, DIDCOMM_SERVICE_TYPE) {
167 caps.didcomm.get_or_insert(uri);
168 } else if REST_SERVICE_TYPES.iter().any(|t| service_has_type(svc, t)) {
169 caps.https.get_or_insert(uri);
170 }
171 }
172 caps
173 }
174
175 #[must_use]
177 pub fn endpoint(&self, kind: TransportKind) -> Option<&str> {
178 match kind {
179 TransportKind::Tsp => self.tsp.as_deref(),
180 TransportKind::Didcomm => self.didcomm.as_deref(),
181 TransportKind::Https => self.https.as_deref(),
182 }
183 }
184
185 #[must_use]
187 pub fn advertised(&self) -> Vec<TransportKind> {
188 PREFERENCE_ORDER
189 .into_iter()
190 .filter(|k| self.endpoint(*k).is_some())
191 .collect()
192 }
193
194 pub fn select(&self, ours: &[TransportKind]) -> Result<TransportChoice, TrqlError> {
201 for kind in PREFERENCE_ORDER {
202 if ours.contains(&kind)
203 && let Some(endpoint) = self.endpoint(kind)
204 {
205 return Ok(TransportChoice {
206 kind,
207 endpoint: endpoint.to_string(),
208 });
209 }
210 }
211 Err(TrqlError::NoMatchingTransport {
212 ours: ours.to_vec(),
213 theirs: self.advertised(),
214 })
215 }
216}
217
218fn service_has_type(svc: &Value, type_: &str) -> bool {
222 match svc.get("type") {
223 Some(Value::String(s)) => s == type_,
224 Some(Value::Array(arr)) => arr.iter().any(|t| t.as_str() == Some(type_)),
225 _ => false,
226 }
227}
228
229fn endpoint_uri(endpoint: &Value) -> Option<String> {
233 match endpoint {
234 Value::String(s) => Some(s.clone()),
235 Value::Object(map) => map.get("uri")?.as_str().map(str::to_string),
236 Value::Array(arr) => arr.iter().find_map(endpoint_uri),
237 _ => None,
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use serde_json::json;
245
246 fn doc(services: Value) -> Value {
247 json!({ "id": "did:webvh:registry.example", "service": services })
248 }
249
250 const ALL: [TransportKind; 3] = [
251 TransportKind::Tsp,
252 TransportKind::Didcomm,
253 TransportKind::Https,
254 ];
255
256 #[test]
257 fn parses_each_service_type() {
258 let caps = ServiceCapabilities::from_document(&doc(json!([
259 { "id": "#tsp", "type": "TSPTransport", "serviceEndpoint": "did:web:mediator" },
260 { "id": "#didcomm", "type": "DIDCommMessaging",
261 "serviceEndpoint": { "uri": "did:web:mediator", "accept": ["didcomm/v2"] } },
262 { "id": "#rest", "type": "TRQPRest", "serviceEndpoint": "https://registry.example" },
263 ])));
264 assert_eq!(caps.tsp.as_deref(), Some("did:web:mediator"));
265 assert_eq!(caps.didcomm.as_deref(), Some("did:web:mediator"));
266 assert_eq!(caps.https.as_deref(), Some("https://registry.example"));
267 }
268
269 #[test]
272 fn tolerates_string_object_and_array_endpoints() {
273 for endpoint in [
274 json!("did:web:mediator"),
275 json!({ "uri": "did:web:mediator", "accept": ["didcomm/v2"] }),
276 json!([{ "uri": "did:web:mediator" }]),
277 ] {
278 let caps = ServiceCapabilities::from_document(&doc(json!([
279 { "id": "#x", "type": "DIDCommMessaging", "serviceEndpoint": endpoint }
280 ])));
281 assert_eq!(caps.didcomm.as_deref(), Some("did:web:mediator"));
282 }
283 }
284
285 #[test]
287 fn matches_on_type_not_fragment() {
288 let caps = ServiceCapabilities::from_document(&doc(json!([
289 { "id": "did:x#tsp-transport", "type": "TSPTransport", "serviceEndpoint": "did:web:m" },
290 { "id": "did:x#tsp", "type": "TRQPRest", "serviceEndpoint": "https://r.example" },
291 ])));
292 assert_eq!(caps.tsp.as_deref(), Some("did:web:m"));
293 assert_eq!(caps.https.as_deref(), Some("https://r.example"));
295 }
296
297 #[test]
298 fn type_may_be_an_array() {
299 let caps = ServiceCapabilities::from_document(&doc(json!([
300 { "id": "#m", "type": ["DIDCommMessaging", "Other"], "serviceEndpoint": "did:web:m" }
301 ])));
302 assert_eq!(caps.didcomm.as_deref(), Some("did:web:m"));
303 }
304
305 #[test]
306 fn ignores_unknown_types_empty_and_missing_endpoints() {
307 let caps = ServiceCapabilities::from_document(&doc(json!([
308 { "id": "#a", "type": "SomethingElse", "serviceEndpoint": "https://x" },
309 { "id": "#b", "type": "TRQPRest", "serviceEndpoint": "" },
310 { "id": "#c", "type": "TSPTransport" },
311 { "id": "#d", "type": "DIDCommMessaging", "serviceEndpoint": 42 },
312 ])));
313 assert_eq!(caps, ServiceCapabilities::default());
314 assert!(caps.advertised().is_empty());
315 }
316
317 #[test]
318 fn document_without_services_yields_nothing() {
319 assert_eq!(
320 ServiceCapabilities::from_document(&json!({ "id": "did:x" })),
321 ServiceCapabilities::default()
322 );
323 }
324
325 #[test]
326 fn first_entry_of_a_type_wins() {
327 let caps = ServiceCapabilities::from_document(&doc(json!([
328 { "id": "#r1", "type": "TRQPRest", "serviceEndpoint": "https://first.example" },
329 { "id": "#r2", "type": "TRQPRest", "serviceEndpoint": "https://second.example" },
330 ])));
331 assert_eq!(caps.https.as_deref(), Some("https://first.example"));
332 }
333
334 #[test]
335 fn selects_the_most_preferred_shared_transport() {
336 let caps = ServiceCapabilities {
337 tsp: Some("did:web:m".into()),
338 didcomm: Some("did:web:m".into()),
339 https: Some("https://r.example".into()),
340 };
341 assert_eq!(caps.select(&ALL).unwrap().kind, TransportKind::Tsp);
342
343 let choice = caps
345 .select(&[TransportKind::Didcomm, TransportKind::Https])
346 .unwrap();
347 assert_eq!(choice.kind, TransportKind::Didcomm);
348 assert_eq!(choice.endpoint, "did:web:m");
349
350 let choice = caps.select(&[TransportKind::Https]).unwrap();
352 assert_eq!(choice.kind, TransportKind::Https);
353 assert_eq!(choice.endpoint, "https://r.example");
354 }
355
356 #[test]
360 fn no_shared_transport_is_a_typed_error_not_a_fallback() {
361 let caps = ServiceCapabilities {
362 didcomm: Some("did:web:m".into()),
363 ..Default::default()
364 };
365 let err = caps.select(&[TransportKind::Https]).unwrap_err();
366 match err {
367 TrqlError::NoMatchingTransport { ours, theirs } => {
368 assert_eq!(ours, vec![TransportKind::Https]);
369 assert_eq!(theirs, vec![TransportKind::Didcomm]);
370 }
371 other => panic!("expected NoMatchingTransport, got {other:?}"),
372 }
373 }
374
375 #[test]
378 fn empty_capabilities_report_an_empty_peer_set() {
379 let err = ServiceCapabilities::default()
380 .select(&ALL)
381 .expect_err("no transports advertised");
382 match err {
383 TrqlError::NoMatchingTransport { theirs, .. } => assert!(theirs.is_empty()),
384 other => panic!("expected NoMatchingTransport, got {other:?}"),
385 }
386 }
387
388 #[test]
389 fn no_matching_transport_is_not_retryable() {
390 let err = ServiceCapabilities::default().select(&ALL).unwrap_err();
391 assert!(!err.is_retryable());
392 }
393
394 #[test]
395 fn service_types_match_the_workspace_constants() {
396 assert_eq!(TransportKind::Tsp.service_type(), "TSPTransport");
397 assert_eq!(TransportKind::Didcomm.service_type(), "DIDCommMessaging");
398 assert_eq!(TransportKind::Https.service_type(), "TRQPRest");
399 }
400
401 #[test]
405 fn both_rest_type_names_are_discovered() {
406 for ty in ["TRQPRest", "VTARest"] {
407 let caps = ServiceCapabilities::from_document(&doc(json!([
408 { "id": "#rest", "type": ty, "serviceEndpoint": "https://r.example" }
409 ])));
410 assert_eq!(
411 caps.https.as_deref(),
412 Some("https://r.example"),
413 "{ty} must be recognised as REST"
414 );
415 }
416 }
417
418 #[test]
419 fn compiled_transports_are_in_preference_order() {
420 let compiled = TransportKind::compiled();
421 let expected: Vec<_> = PREFERENCE_ORDER
422 .into_iter()
423 .filter(|k| compiled.contains(k))
424 .collect();
425 assert_eq!(compiled, expected);
426 #[cfg(feature = "https")]
428 assert!(compiled.contains(&TransportKind::Https));
429 }
430}