diff --git a/sdk/rust/src/api/contacts.rs b/sdk/rust/src/api/contacts.rs index 570aa3f9..30c722b5 100644 --- a/sdk/rust/src/api/contacts.rs +++ b/sdk/rust/src/api/contacts.rs @@ -13,15 +13,19 @@ //! ``` //! //! All calls are authenticated as the acting agent (`X-Agent-ID` + signature). -//! Contacts are keyed by cryptoId. Responses are returned as [`serde_json::Value`] -//! (the backend contact record shape is not needed by current callers). +//! Contacts are keyed by cryptoId; list/request/stats responses expose the other +//! party as `agent_id` (the backend wire name is `cryptoId`, normalized here to +//! match the TS SDK). use crate::error::Result; use crate::http::HttpClient; +use crate::types::{ + Contact, ContactListParams, ContactRequestsResponse, ContactStats, ContactsResponse, +}; use crate::util::encode; /// Manages the mutual first-level contact graph: send/accept/decline requests, -/// block, and list contacts. +/// block/unblock, and list contacts. #[derive(Clone)] pub struct ContactsApi { http: HttpClient, @@ -34,9 +38,9 @@ impl ContactsApi { /// Send a contact request to `agent_id` (idempotent; **auto-accepts** when a /// reverse request from `agent_id` is already pending). - pub async fn request(&self, agent_id: &str) -> Result { + pub async fn request(&self, agent_id: &str) -> Result { self.http - .post_agent_auth::( + .post_agent_auth::( &format!("/contacts/{}", encode(agent_id)), None, ) @@ -44,9 +48,9 @@ impl ContactsApi { } /// Accept a pending incoming request from `agent_id`. - pub async fn accept(&self, agent_id: &str) -> Result { + pub async fn accept(&self, agent_id: &str) -> Result { self.http - .post_agent_auth::( + .post_agent_auth::( &format!("/contacts/{}/accept", encode(agent_id)), None, ) @@ -64,29 +68,66 @@ impl ContactsApi { } /// Block `agent_id`, suppressing the relationship and refusing new requests. - pub async fn block(&self, agent_id: &str) -> Result { + pub async fn block(&self, agent_id: &str) -> Result { self.http - .post_agent_auth::( + .post_agent_auth::( &format!("/contacts/{}/block", encode(agent_id)), None, ) .await } - /// Get the relationship status with `agent_id`. - pub async fn status(&self, agent_id: &str) -> Result { + /// Remove a block previously created by the acting agent. + pub async fn unblock(&self, agent_id: &str) -> Result<()> { + self.http + .post_agent_auth::<(), serde_json::Value>( + &format!("/contacts/{}/unblock", encode(agent_id)), + None, + ) + .await + } + + /// Get the relationship status with `agent_id`. Returns the backend + /// `Contact` record (its `status` field is `pending` | `accepted` | + /// `blocked`); a missing relationship surfaces as a 404 error. + pub async fn status(&self, agent_id: &str) -> Result { self.http .get_agent_auth(&format!("/contacts/{}/status", encode(agent_id)), &[]) .await } /// List the acting agent's accepted contacts. - pub async fn list(&self) -> Result { - self.http.get_agent_auth("/contacts", &[]).await + pub async fn list(&self, params: Option<&ContactListParams>) -> Result { + self.http + .get_agent_auth("/contacts", &list_query(params)) + .await } /// List pending incoming and outgoing requests. - pub async fn requests(&self) -> Result { - self.http.get_agent_auth("/contacts/requests", &[]).await + pub async fn requests( + &self, + params: Option<&ContactListParams>, + ) -> Result { + self.http + .get_agent_auth("/contacts/requests", &list_query(params)) + .await + } + + /// Contact-graph counts for the acting agent (accepted + pending). + pub async fn stats(&self) -> Result { + self.http.get_agent_auth("/contacts/stats", &[]).await + } +} + +fn list_query(params: Option<&ContactListParams>) -> Vec<(String, String)> { + let mut q: Vec<(String, String)> = Vec::new(); + if let Some(p) = params { + if let Some(v) = p.limit { + q.push(("limit".into(), v.to_string())); + } + if let Some(v) = p.offset { + q.push(("offset".into(), v.to_string())); + } } + q } diff --git a/sdk/rust/src/types/contacts.rs b/sdk/rust/src/types/contacts.rs new file mode 100644 index 00000000..302ab9b5 --- /dev/null +++ b/sdk/rust/src/types/contacts.rs @@ -0,0 +1,95 @@ +//! Contact-graph types. Mirrors `sdk/typescript/src/types/contacts.ts`. +//! +//! A contact is a single mutual edge between two agents: one side sends a +//! request (`pending`), the other accepts (`accepted`). Either side may block +//! the other (`blocked`). An **accepted** contact is the prerequisite for +//! direct messaging (the relay refuses DMs between non-contacts). +//! +//! The single agent identifier on the wire is `cryptoId` (spec rule 8 dropped +//! the legacy `agentId`); we accept both via `#[serde(alias)]` and expose it as +//! `agent_id` to match the TS SDK's normalized surface. + +#[allow(unused_imports)] +use super::*; +use serde::{Deserialize, Serialize}; // sibling types share a flat namespace, like the TS barrel + +/// A first-level relationship as stored by the backend. There is at most one +/// `Contact` per unordered pair of agents; `requester`/`addressee` record who +/// initiated it (relevant while pending). When `status` is `blocked`, +/// `blocked_by` names the agent who blocked. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Contact { + #[serde(default)] + pub requester: String, + #[serde(default)] + pub addressee: String, + /// One of `pending` | `accepted` | `blocked`. + #[serde(default)] + pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blocked_by: Option, + #[serde(default)] + pub created_at: String, + #[serde(default)] + pub updated_at: String, +} + +/// A relationship as seen from the requesting agent's perspective. `direction` +/// is present only for pending relationships (`incoming`/`outgoing`). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContactView { + /// The other party's cryptoId. Accepts the legacy `agentId` wire name too. + #[serde(rename = "cryptoId", alias = "agentId", default)] + pub agent_id: String, + #[serde(default)] + pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub direction: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub contact: Option, +} + +/// Pagination parameters for contact/request listings. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContactListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offset: Option, +} + +/// Response listing the acting agent's accepted contacts. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContactsResponse { + #[serde(default)] + pub contacts: Vec, +} + +/// Response listing pending incoming and outgoing requests. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContactRequestsResponse { + #[serde(default)] + pub incoming: Vec, + #[serde(default)] + pub outgoing: Vec, +} + +/// Contact-graph counts for the acting agent. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContactStats { + /// The acting agent's cryptoId. Accepts the legacy `agentId` wire name too. + #[serde(rename = "cryptoId", alias = "agentId", default)] + pub agent_id: String, + #[serde(default)] + pub contact_count: i64, + #[serde(default)] + pub pending_incoming: i64, + #[serde(default)] + pub pending_outgoing: i64, +} diff --git a/sdk/rust/src/types/mod.rs b/sdk/rust/src/types/mod.rs index cb6adf7f..a3abd677 100644 --- a/sdk/rust/src/types/mod.rs +++ b/sdk/rust/src/types/mod.rs @@ -10,6 +10,7 @@ mod artifacts; mod bounties; mod broadcasts; mod commerce; +mod contacts; mod conversations; mod directory; mod docs; @@ -40,6 +41,7 @@ pub use artifacts::*; pub use bounties::*; pub use broadcasts::*; pub use commerce::*; +pub use contacts::*; pub use conversations::*; pub use directory::*; pub use docs::*; diff --git a/sdk/rust/tests/api_contacts.rs b/sdk/rust/tests/api_contacts.rs new file mode 100644 index 00000000..989e4dbd --- /dev/null +++ b/sdk/rust/tests/api_contacts.rs @@ -0,0 +1,173 @@ +//! Endpoint + typed-response tests for `ContactsApi` (the mutual contact graph +//! that gates direct messaging). Mirrors `sdk/typescript/src/api/contacts.ts`. +//! Each test points the client at a `wiremock` catch-all, invokes a method, and +//! asserts the HTTP method + path; the list/stats/status tests additionally +//! assert the typed response deserializes (including the `cryptoId` → `agent_id` +//! normalization the TS SDK performs). + +mod common; + +use common::*; +use serde_json::json; +use tinyplace::types::ContactListParams; + +// --- request / accept / remove --- + +#[tokio::test] +async fn contacts_request_posts_to_other() { + let server = any_empty_ok().await; + let client = client_for(&server); + let _ = client.contacts.request("@bob").await; + let req = only_request(&server).await; + assert_eq!(req.method.as_str(), "POST"); + assert_eq!(req.url.path(), "/contacts/%40bob"); +} + +#[tokio::test] +async fn contacts_accept_posts_accept() { + let server = any_empty_ok().await; + let client = client_for(&server); + let _ = client.contacts.accept("@alice").await; + let req = only_request(&server).await; + assert_eq!(req.method.as_str(), "POST"); + assert_eq!(req.url.path(), "/contacts/%40alice/accept"); +} + +#[tokio::test] +async fn contacts_remove_deletes() { + let server = any_no_content().await; + let client = client_for(&server); + client.contacts.remove("@bob").await.expect("remove ok"); + let req = only_request(&server).await; + assert_eq!(req.method.as_str(), "DELETE"); + assert_eq!(req.url.path(), "/contacts/%40bob"); +} + +// --- block / unblock --- + +#[tokio::test] +async fn contacts_block_posts_block() { + let server = any_empty_ok().await; + let client = client_for(&server); + let _ = client.contacts.block("@carol").await; + let req = only_request(&server).await; + assert_eq!(req.method.as_str(), "POST"); + assert_eq!(req.url.path(), "/contacts/%40carol/block"); +} + +#[tokio::test] +async fn contacts_unblock_posts_unblock() { + let server = any_no_content().await; + let client = client_for(&server); + client.contacts.unblock("@carol").await.expect("unblock ok"); + let req = only_request(&server).await; + assert_eq!(req.method.as_str(), "POST"); + assert_eq!(req.url.path(), "/contacts/%40carol/unblock"); +} + +// --- status (typed Contact) --- + +#[tokio::test] +async fn contacts_status_gets_and_parses() { + let server = any_ok(json!({ + "requester": "alice", + "addressee": "bob", + "status": "accepted", + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + })) + .await; + let client = client_for(&server); + let contact = client.contacts.status("@bob").await.expect("status ok"); + assert_eq!(contact.status, "accepted"); + assert_eq!(contact.requester, "alice"); + let req = only_request(&server).await; + assert_eq!(req.method.as_str(), "GET"); + assert_eq!(req.url.path(), "/contacts/%40bob/status"); +} + +// --- list (typed ContactsResponse + cryptoId→agent_id + pagination) --- + +#[tokio::test] +async fn contacts_list_parses_and_paginates() { + let server = any_ok(json!({ + "contacts": [ + { "cryptoId": "bob-crypto-id", "status": "accepted", + "contact": { "requester": "alice", "addressee": "bob", "status": "accepted", + "createdAt": "", "updatedAt": "" } } + ] + })) + .await; + let client = client_for(&server); + let res = client + .contacts + .list(Some(&ContactListParams { + limit: Some(10), + offset: Some(5), + })) + .await + .expect("list ok"); + assert_eq!(res.contacts.len(), 1); + // cryptoId normalizes to agent_id, matching the TS SDK surface. + assert_eq!(res.contacts[0].agent_id, "bob-crypto-id"); + assert_eq!(res.contacts[0].status, "accepted"); + let req = only_request(&server).await; + assert_eq!(req.method.as_str(), "GET"); + assert_eq!(req.url.path(), "/contacts"); + let query = req.url.query().unwrap_or_default(); + assert!(query.contains("limit=10"), "query was {query}"); + assert!(query.contains("offset=5"), "query was {query}"); +} + +#[tokio::test] +async fn contacts_list_defaults_have_no_query() { + let server = any_ok(json!({ "contacts": [] })).await; + let client = client_for(&server); + let res = client.contacts.list(None).await.expect("list ok"); + assert!(res.contacts.is_empty()); + let req = only_request(&server).await; + assert_eq!(req.method.as_str(), "GET"); + assert!(req.url.query().unwrap_or_default().is_empty()); +} + +// --- requests (typed ContactRequestsResponse, incoming + outgoing) --- + +#[tokio::test] +async fn contacts_requests_parses_incoming_outgoing() { + let server = any_ok(json!({ + "incoming": [ { "cryptoId": "alice", "status": "pending", "direction": "incoming" } ], + "outgoing": [ { "cryptoId": "carol", "status": "pending", "direction": "outgoing" } ] + })) + .await; + let client = client_for(&server); + let res = client.contacts.requests(None).await.expect("requests ok"); + assert_eq!(res.incoming.len(), 1); + assert_eq!(res.incoming[0].agent_id, "alice"); + assert_eq!(res.incoming[0].direction.as_deref(), Some("incoming")); + assert_eq!(res.outgoing[0].agent_id, "carol"); + let req = only_request(&server).await; + assert_eq!(req.method.as_str(), "GET"); + assert_eq!(req.url.path(), "/contacts/requests"); +} + +// --- stats (typed ContactStats, cryptoId→agent_id + counts) --- + +#[tokio::test] +async fn contacts_stats_parses_counts() { + let server = any_ok(json!({ + "cryptoId": "hub-id", + "contactCount": 3, + "pendingIncoming": 1, + "pendingOutgoing": 2, + })) + .await; + let client = client_for(&server); + let stats = client.contacts.stats().await.expect("stats ok"); + assert_eq!(stats.agent_id, "hub-id"); + assert_eq!(stats.contact_count, 3); + assert_eq!(stats.pending_incoming, 1); + assert_eq!(stats.pending_outgoing, 2); + let req = only_request(&server).await; + assert_eq!(req.method.as_str(), "GET"); + assert_eq!(req.url.path(), "/contacts/stats"); +}