Skip to main content

trql_client/
client.rs

1//! The typed query client: builds request documents, delegates the exchange to
2//! a [`TrqlTransport`], and owns all document semantics (correlation, error
3//! mapping, payload typing) so every binding behaves identically.
4
5use 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
18/// Slug of the framework's reserved error document type.
19const ERROR_SLUG: &str = "trust-task-error";
20
21/// The TRQP 4-tuple every registry query is keyed on, plus the optional
22/// evaluation context.
23#[derive(Debug, Clone)]
24pub struct TrqpQuery {
25    /// DID of the entity whose trust is being checked.
26    pub entity_id: String,
27    /// DID of the governing authority.
28    pub authority_id: String,
29    /// The action being checked (e.g. `issue`, `git.commit.sign`).
30    pub action: String,
31    /// The resource the action applies to.
32    pub resource: String,
33    /// Evaluate as of this instant instead of "now", when set.
34    pub time: Option<DateTime<Utc>>,
35    /// Authority-defined record-location hint.
36    pub locator: Option<String>,
37}
38
39impl TrqpQuery {
40    /// A query over the TRQP 4-tuple, evaluated at the registry's current time.
41    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    /// Request evaluation as of `time` instead of the registry's current time.
58    pub fn at(mut self, time: DateTime<Utc>) -> Self {
59        self.time = Some(time);
60        self
61    }
62
63    /// Attach an authority-defined locator hint.
64    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
74/// Transport-agnostic Trust Registry query client.
75///
76/// Holds one [`TrqlTransport`] and the registry's DID (the `recipient` stamped
77/// on every request — the registry rejects documents addressed to anyone
78/// else). Swapping HTTPS for DIDComm or TSP changes only the transport handed
79/// to [`TrqlClient::new`].
80pub struct TrqlClient {
81    transport: Arc<dyn TrqlTransport>,
82    registry_did: String,
83    client_did: Option<String>,
84}
85
86impl TrqlClient {
87    /// A client that queries the registry identified by `registry_did` over
88    /// `transport`.
89    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    /// Set the in-band `issuer` on outbound documents. Optional over HTTPS
98    /// (the registry treats queries as anonymous reads); DIDComm and TSP
99    /// authenticate the sender at the transport layer regardless.
100    pub fn with_client_did(mut self, did: impl Into<String>) -> Self {
101        self.client_did = Some(did.into());
102        self
103    }
104
105    /// Ask whether `entity` is authorized by `authority` for `action` on
106    /// `resource` (`registry/authorization/0.1`).
107    ///
108    /// A registry with no matching record answers `authorized: false` — absence
109    /// is a denial, not an error.
110    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    /// Ask whether `entity` is recognized by `authority` for `action` on
132    /// `resource` (`registry/recognition/0.1`).
133    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    /// Build the request document for `payload`, run one exchange, and
152    /// validate the reply: correlated (`threadId` == request `id`), then
153    /// either the matching `#response` document or a `trust-task-error`
154    /// mapped to [`TrqlError::Rejected`].
155    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        // Correlation is checked, never assumed: an uncorrelated document is
172        // someone else's reply and must not be interpreted as ours.
173        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
204/// A fresh `urn:uuid:` document id.
205fn 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// Without the mediator transports the crate has no uuid dependency; derive the
215// id from entropy the std library already gives us. Uniqueness only needs to
216// hold within this process's in-flight queries.
217#[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    /// A transport that answers every request with a canned closure.
239    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            // The seam carries untyped documents; reserialize like a wire hop.
315            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            // `authorized` missing: the strict payload must fail to parse.
342            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}