1use std::sync::Arc;
6
7use chrono::{DateTime, Utc};
8use serde::Serialize;
9use serde::de::DeserializeOwned;
10use trust_tasks_rs::{ErrorPayload, Payload, TrustTask};
11
12use crate::error::TrqlError;
13use crate::payloads::{
14 AuthorizationRequest, AuthorizationResponse, RecognitionRequest, RecognitionResponse,
15};
16use crate::transport::TrqlTransport;
17
18const ERROR_SLUG: &str = "trust-task-error";
20
21#[derive(Debug, Clone)]
24pub struct TrqpQuery {
25 pub entity_id: String,
27 pub authority_id: String,
29 pub action: String,
31 pub resource: String,
33 pub time: Option<DateTime<Utc>>,
35 pub locator: Option<String>,
37}
38
39impl TrqpQuery {
40 pub fn new(
42 entity_id: impl Into<String>,
43 authority_id: impl Into<String>,
44 action: impl Into<String>,
45 resource: impl Into<String>,
46 ) -> Self {
47 Self {
48 entity_id: entity_id.into(),
49 authority_id: authority_id.into(),
50 action: action.into(),
51 resource: resource.into(),
52 time: None,
53 locator: None,
54 }
55 }
56
57 pub fn at(mut self, time: DateTime<Utc>) -> Self {
59 self.time = Some(time);
60 self
61 }
62
63 pub fn locator(mut self, locator: impl Into<String>) -> Self {
65 self.locator = Some(locator.into());
66 self
67 }
68
69 fn has_context(&self) -> bool {
70 self.time.is_some() || self.locator.is_some()
71 }
72}
73
74pub struct TrqlClient {
81 transport: Arc<dyn TrqlTransport>,
82 registry_did: String,
83 client_did: Option<String>,
84}
85
86impl TrqlClient {
87 pub fn new(transport: Arc<dyn TrqlTransport>, registry_did: impl Into<String>) -> Self {
90 Self {
91 transport,
92 registry_did: registry_did.into(),
93 client_did: None,
94 }
95 }
96
97 pub fn with_client_did(mut self, did: impl Into<String>) -> Self {
101 self.client_did = Some(did.into());
102 self
103 }
104
105 pub async fn authorization(
111 &self,
112 query: TrqpQuery,
113 ) -> Result<AuthorizationResponse, TrqlError> {
114 let payload = AuthorizationRequest {
115 entity_id: query.entity_id.clone(),
116 authority_id: query.authority_id.clone(),
117 action: query.action.clone(),
118 resource: query.resource.clone(),
119 context: query
120 .has_context()
121 .then(|| crate::payloads::AuthorizationQueryContext {
122 time: query.time,
123 locator: query.locator.clone(),
124 extra: Default::default(),
125 }),
126 ext: None,
127 };
128 self.send_query(payload).await
129 }
130
131 pub async fn recognition(&self, query: TrqpQuery) -> Result<RecognitionResponse, TrqlError> {
134 let payload = RecognitionRequest {
135 entity_id: query.entity_id.clone(),
136 authority_id: query.authority_id.clone(),
137 action: query.action.clone(),
138 resource: query.resource.clone(),
139 context: query
140 .has_context()
141 .then(|| crate::payloads::RecognitionQueryContext {
142 time: query.time,
143 locator: query.locator.clone(),
144 extra: Default::default(),
145 }),
146 ext: None,
147 };
148 self.send_query(payload).await
149 }
150
151 async fn send_query<Req, Resp>(&self, payload: Req) -> Result<Resp, TrqlError>
156 where
157 Req: Payload + Serialize,
158 Resp: DeserializeOwned,
159 {
160 let body = serde_json::to_value(&payload)
161 .map_err(|e| TrqlError::Contract(format!("request payload did not serialize: {e}")))?;
162 let id = new_task_id();
163 let mut request = TrustTask::new(id.clone(), Req::type_uri(), body);
164 request.recipient = Some(self.registry_did.clone());
165 request.issuer = self.client_did.clone();
166 request.issued_at = Some(Utc::now());
167 let request_slug = request.type_uri.slug().to_string();
168
169 let reply = self.transport.exchange(request).await?;
170
171 if reply.thread_id.as_deref() != Some(id.as_str()) {
174 return Err(TrqlError::Contract(format!(
175 "uncorrelated reply: threadId {:?} does not match request id {id}",
176 reply.thread_id
177 )));
178 }
179
180 if reply.type_uri.slug() == ERROR_SLUG {
181 let error: ErrorPayload = serde_json::from_value(reply.payload).map_err(|e| {
182 TrqlError::Contract(format!("trust-task-error payload did not parse: {e}"))
183 })?;
184 return Err(TrqlError::Rejected {
185 code: error.code,
186 retryable: error.retryable,
187 retry_after: error.retry_after,
188 message: error.message,
189 });
190 }
191
192 if !(reply.type_uri.is_response() && reply.type_uri.slug() == request_slug) {
193 return Err(TrqlError::Contract(format!(
194 "unexpected reply type `{}` to a `{request_slug}` request",
195 reply.type_uri
196 )));
197 }
198
199 serde_json::from_value(reply.payload)
200 .map_err(|e| TrqlError::Contract(format!("response payload did not parse: {e}")))
201 }
202}
203
204fn new_task_id() -> String {
206 format!("urn:uuid:{}", uuid_v4())
207}
208
209#[cfg(any(feature = "didcomm", feature = "tsp"))]
210fn uuid_v4() -> String {
211 uuid::Uuid::new_v4().to_string()
212}
213
214#[cfg(not(any(feature = "didcomm", feature = "tsp")))]
218fn uuid_v4() -> String {
219 use std::sync::atomic::{AtomicU64, Ordering};
220 use std::time::{SystemTime, UNIX_EPOCH};
221 static COUNTER: AtomicU64 = AtomicU64::new(0);
222 let nanos = SystemTime::now()
223 .duration_since(UNIX_EPOCH)
224 .map(|d| d.as_nanos())
225 .unwrap_or(0);
226 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
227 format!("{nanos:032x}-{n:016x}")
228}
229
230#[cfg(test)]
231mod tests {
232 #![allow(clippy::unwrap_used, clippy::expect_used)]
233
234 use super::*;
235 use serde_json::Value;
236 use trust_tasks_rs::RejectReason;
237
238 struct MockTransport<F>(F)
240 where
241 F: Fn(TrustTask<Value>) -> Result<TrustTask<Value>, TrqlError> + Send + Sync;
242
243 #[async_trait::async_trait]
244 impl<F> TrqlTransport for MockTransport<F>
245 where
246 F: Fn(TrustTask<Value>) -> Result<TrustTask<Value>, TrqlError> + Send + Sync,
247 {
248 fn kind(&self) -> crate::TransportKind {
249 crate::TransportKind::Https
250 }
251
252 async fn exchange(&self, request: TrustTask<Value>) -> Result<TrustTask<Value>, TrqlError> {
253 (self.0)(request)
254 }
255 }
256
257 fn client_over<F>(f: F) -> TrqlClient
258 where
259 F: Fn(TrustTask<Value>) -> Result<TrustTask<Value>, TrqlError> + Send + Sync + 'static,
260 {
261 TrqlClient::new(Arc::new(MockTransport(f)), "did:example:registry")
262 }
263
264 fn authorized_response(request: &TrustTask<Value>, authorized: bool) -> TrustTask<Value> {
265 let payload = serde_json::json!({
266 "entity_id": request.payload["entity_id"],
267 "authority_id": request.payload["authority_id"],
268 "action": request.payload["action"],
269 "resource": request.payload["resource"],
270 "authorized": authorized,
271 "time_evaluated": "2026-07-16T00:00:00Z",
272 });
273 request.respond_with("urn:uuid:reply".to_string(), payload)
274 }
275
276 fn query() -> TrqpQuery {
277 TrqpQuery::new("did:example:e", "did:example:a", "issue", "vc")
278 }
279
280 #[tokio::test]
281 async fn authorization_round_trip_stamps_recipient_and_parses_reply() {
282 let client = client_over(|req| {
283 assert_eq!(req.recipient.as_deref(), Some("did:example:registry"));
284 assert!(req.issued_at.is_some());
285 assert_eq!(req.type_uri.slug(), "registry/authorization");
286 Ok(authorized_response(&req, true))
287 });
288 let response = client.authorization(query()).await.unwrap();
289 assert!(response.authorized);
290 assert_eq!(response.entity_id, "did:example:e");
291 }
292
293 #[tokio::test]
294 async fn uncorrelated_reply_is_a_contract_error() {
295 let client = client_over(|req| {
296 let mut reply = authorized_response(&req, true);
297 reply.thread_id = Some("urn:uuid:someone-else".to_string());
298 Ok(reply)
299 });
300 let err = client.authorization(query()).await.unwrap_err();
301 assert!(matches!(err, TrqlError::Contract(_)), "got: {err}");
302 assert!(!err.is_retryable());
303 }
304
305 #[tokio::test]
306 async fn error_document_maps_to_rejected_with_code() {
307 let client = client_over(|req| {
308 let error_doc = req.reject_with(
309 "urn:uuid:err".to_string(),
310 RejectReason::PermissionDenied {
311 reason: "not allowed".to_string(),
312 },
313 );
314 let as_value = serde_json::to_value(&error_doc).unwrap();
316 Ok(serde_json::from_value(as_value).unwrap())
317 });
318 let err = client.authorization(query()).await.unwrap_err();
319 match err {
320 TrqlError::Rejected { retryable, .. } => assert!(!retryable),
321 other => panic!("expected Rejected, got {other}"),
322 }
323 }
324
325 #[tokio::test]
326 async fn wrong_response_type_is_a_contract_error() {
327 let client = client_over(|req| {
328 let mut reply = authorized_response(&req, true);
329 reply.type_uri = "https://trusttasks.org/spec/registry/recognition/0.1#response"
330 .parse()
331 .unwrap();
332 Ok(reply)
333 });
334 let err = client.authorization(query()).await.unwrap_err();
335 assert!(matches!(err, TrqlError::Contract(_)), "got: {err}");
336 }
337
338 #[tokio::test]
339 async fn malformed_response_payload_is_a_contract_error_not_transport() {
340 let client = client_over(|req| {
341 let payload = serde_json::json!({ "unexpected": true });
343 Ok(req.respond_with("urn:uuid:reply".to_string(), payload))
344 });
345 let err = client.authorization(query()).await.unwrap_err();
346 assert!(matches!(err, TrqlError::Contract(_)), "got: {err}");
347 assert!(!err.is_retryable());
348 }
349
350 #[tokio::test]
351 async fn recognition_parses_recognized_flag() {
352 let client = client_over(|req| {
353 let payload = serde_json::json!({
354 "entity_id": req.payload["entity_id"],
355 "authority_id": req.payload["authority_id"],
356 "action": req.payload["action"],
357 "resource": req.payload["resource"],
358 "recognized": false,
359 "time_evaluated": "2026-07-16T00:00:00Z",
360 });
361 Ok(req.respond_with("urn:uuid:reply".to_string(), payload))
362 });
363 let response = client.recognition(query()).await.unwrap();
364 assert!(!response.recognized, "absence of trust reads as false");
365 }
366
367 #[tokio::test]
368 async fn query_context_is_sent_when_time_is_set() {
369 let at = "2026-01-01T00:00:00Z".parse().unwrap();
370 let client = client_over(|req| {
371 assert_eq!(
372 req.payload["context"]["time"], "2026-01-01T00:00:00Z",
373 "context.time must be carried on the wire"
374 );
375 Ok(authorized_response(&req, true))
376 });
377 client.authorization(query().at(at)).await.unwrap();
378 }
379}