use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use affinidi_messaging_didcomm_service::DIDCommService;
use affinidi_tdk::didcomm::Message;
use tokio::sync::oneshot;
use crate::error::{AppError, bad_gateway_error};
use vta_sdk::protocols::{PROBLEM_REPORT_TYPE, extract_problem_report};
pub type PendingMap = Arc<std::sync::Mutex<HashMap<String, oneshot::Sender<Message>>>>;
fn problem_report_to_app_error(code: &str, comment: &str) -> AppError {
let detail = format!("remote peer rejected the request: {comment} [{code}]");
match code.rsplit('.').next().unwrap_or_default() {
"unauthorized" | "forbidden" => AppError::Forbidden(detail),
"path-unavailable" | "conflict" => AppError::Conflict(detail),
"mnemonic-not-found" | "not-found" => AppError::NotFound(detail),
"path-invalid" | "invalid-log" | "witness-invalid" | "validation-error" | "bad-request"
| "replay-detected" | "size-exceeded" | "quota-exceeded" => AppError::Validation(detail),
_ => bad_gateway_error(detail),
}
}
pub struct DIDCommBridge {
service: tokio::sync::OnceCell<DIDCommService>,
pending: PendingMap,
listener_id: String,
}
impl DIDCommBridge {
pub fn new(listener_id: impl Into<String>) -> Self {
Self {
service: tokio::sync::OnceCell::new(),
pending: Arc::new(std::sync::Mutex::new(HashMap::new())),
listener_id: listener_id.into(),
}
}
pub fn placeholder() -> Self {
Self::new("")
}
pub fn set_service(&self, service: DIDCommService) {
let _ = self.service.set(service);
}
pub fn try_get_service(&self) -> Option<DIDCommService> {
self.service.get().cloned()
}
pub async fn send_oneway(
&self,
listener_id: &str,
recipient_did: &str,
msg_type: &str,
body: serde_json::Value,
) -> Result<(), AppError> {
let service = self
.service
.get()
.ok_or_else(|| AppError::Internal("DIDComm service not initialized".into()))?;
let vta_did = service.listener_did(listener_id).await.ok_or_else(|| {
AppError::Internal(format!(
"listener '{listener_id}' not found in DIDComm service"
))
})?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let msg = Message::build(uuid::Uuid::new_v4().to_string(), msg_type.to_string(), body)
.from(vta_did)
.to(recipient_did.to_string())
.created_time(now)
.finalize();
service
.send_message_with_retry(listener_id, msg, recipient_did, 3, Duration::from_secs(2))
.await
.map_err(|e| bad_gateway_error(format!("failed to send message: {e}")))?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn send_and_wait_via(
&self,
listener_id: &str,
recipient_did: &str,
msg_type: &str,
body: serde_json::Value,
expected_type: &str,
problem_report_type: &str,
timeout_secs: u64,
) -> Result<Message, AppError> {
let service = self
.service
.get()
.ok_or_else(|| AppError::Internal("DIDComm service not initialized".into()))?;
let vta_did = service.listener_did(listener_id).await.ok_or_else(|| {
AppError::Internal(format!(
"listener '{listener_id}' not found in DIDComm service",
))
})?;
let msg_id = uuid::Uuid::new_v4().to_string();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let msg = Message::build(msg_id.clone(), msg_type.to_string(), body)
.from(vta_did.clone())
.to(recipient_did.to_string())
.created_time(now)
.expires_time(now + timeout_secs)
.finalize();
let (tx, rx) = oneshot::channel();
self.pending.lock().unwrap().insert(msg_id.clone(), tx);
service
.send_message_with_retry(listener_id, msg, recipient_did, 3, Duration::from_secs(2))
.await
.map_err(|e| {
self.pending.lock().unwrap().remove(&msg_id);
bad_gateway_error(format!("failed to send message: {e}"))
})?;
let response = tokio::time::timeout(Duration::from_secs(timeout_secs), rx)
.await
.map_err(|_| {
self.pending.lock().unwrap().remove(&msg_id);
bad_gateway_error("timeout waiting for DIDComm response".to_string())
})?
.map_err(|_| bad_gateway_error("pending request channel dropped".to_string()))?;
if response.typ == problem_report_type || response.typ == PROBLEM_REPORT_TYPE {
let (code, comment) = extract_problem_report(&response.body);
return Err(problem_report_to_app_error(&code, &comment));
}
if response.typ != expected_type {
return Err(bad_gateway_error(format!(
"unexpected response type: expected {expected_type}, got {}",
response.typ
)));
}
Ok(response)
}
pub fn try_complete(&self, msg: &Message) -> bool {
if let Some(thid) = &msg.thid
&& let Some(tx) = self.pending.lock().unwrap().remove(thid)
{
let _ = tx.send(msg.clone());
return true;
}
false
}
#[allow(clippy::too_many_arguments)]
pub async fn send_and_wait(
&self,
server_did: &str,
msg_type: &str,
body: serde_json::Value,
expected_type: &str,
problem_report_type: &str,
timeout_secs: u64,
) -> Result<Message, AppError> {
let service = self
.service
.get()
.ok_or_else(|| AppError::Internal("DIDComm service not initialized".into()))?;
let vta_did = service
.listener_did(&self.listener_id)
.await
.ok_or_else(|| {
AppError::Internal(format!(
"listener '{}' not found in DIDComm service",
self.listener_id
))
})?;
let msg_id = uuid::Uuid::new_v4().to_string();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let msg = Message::build(msg_id.clone(), msg_type.to_string(), body)
.from(vta_did.clone())
.to(server_did.to_string())
.created_time(now)
.expires_time(now + timeout_secs)
.finalize();
let (tx, rx) = oneshot::channel();
self.pending.lock().unwrap().insert(msg_id.clone(), tx);
service
.send_message_with_retry(
&self.listener_id,
msg,
server_did,
3,
Duration::from_secs(2),
)
.await
.map_err(|e| {
self.pending.lock().unwrap().remove(&msg_id);
bad_gateway_error(format!("failed to send message: {e}"))
})?;
let response = tokio::time::timeout(Duration::from_secs(timeout_secs), rx)
.await
.map_err(|_| {
self.pending.lock().unwrap().remove(&msg_id);
bad_gateway_error("timeout waiting for DIDComm response".to_string())
})?
.map_err(|_| bad_gateway_error("pending request channel dropped".to_string()))?;
if response.typ == problem_report_type || response.typ == PROBLEM_REPORT_TYPE {
let (code, comment) = extract_problem_report(&response.body);
return Err(problem_report_to_app_error(&code, &comment));
}
if response.typ != expected_type {
return Err(bad_gateway_error(format!(
"unexpected response type: expected {expected_type}, got {}",
response.typ
)));
}
Ok(response)
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
use axum::response::IntoResponse;
fn status_of(code: &str) -> StatusCode {
problem_report_to_app_error(code, "boom")
.into_response()
.status()
}
#[test]
fn remote_client_errors_keep_their_meaning() {
assert_eq!(status_of("e.p.did.path-invalid"), StatusCode::BAD_REQUEST);
assert_eq!(status_of("e.p.did.invalid-log"), StatusCode::BAD_REQUEST);
assert_eq!(
status_of("e.p.did.witness-invalid"),
StatusCode::BAD_REQUEST
);
assert_eq!(
status_of("e.p.did.validation-error"),
StatusCode::BAD_REQUEST
);
assert_eq!(status_of("e.p.did.quota-exceeded"), StatusCode::BAD_REQUEST);
assert_eq!(status_of("e.p.did.size-exceeded"), StatusCode::BAD_REQUEST);
assert_eq!(
status_of("e.p.did.replay-detected"),
StatusCode::BAD_REQUEST
);
assert_eq!(status_of("e.p.did.path-unavailable"), StatusCode::CONFLICT);
assert_eq!(
status_of("e.p.did.mnemonic-not-found"),
StatusCode::NOT_FOUND
);
}
#[test]
fn remote_auth_denial_is_forbidden_not_unauthorized() {
for code in [
"e.p.did.unauthorized",
"e.p.registration.unauthorized",
"e.p.stats.unauthorized",
"e.p.msg.forbidden",
] {
assert_eq!(status_of(code), StatusCode::FORBIDDEN, "code {code}");
}
}
#[test]
fn upstream_failures_and_unknown_codes_stay_bad_gateway() {
for code in [
"e.p.did.internal-error",
"e.p.registration.internal-error",
"e.p.did.some-code-we-have-never-seen",
"",
] {
assert_eq!(status_of(code), StatusCode::BAD_GATEWAY, "code {code}");
}
}
#[test]
fn detail_carries_remote_comment_and_code() {
let err = problem_report_to_app_error(
"e.p.did.path-invalid",
"path segments must contain only lowercase letters, digits, and hyphens",
);
let msg = err.to_string();
assert!(msg.contains("lowercase letters"), "lost comment: {msg}");
assert!(msg.contains("e.p.did.path-invalid"), "lost code: {msg}");
}
}