Skip to main content

calimero_client/
client.rs

1//! API client for Calimero services
2//!
3//! This module provides the core client functionality for making
4//! authenticated API requests to Calimero services.
5
6// Standard library
7use std::str::FromStr;
8
9// External crates
10use calimero_primitives::alias::{Alias, ScopedAlias};
11use calimero_primitives::application::ApplicationId;
12use calimero_primitives::blobs::{BlobId, BlobInfo, BlobMetadata};
13use calimero_primitives::context::ContextId;
14use calimero_primitives::hash::Hash;
15use calimero_primitives::identity::PublicKey;
16use calimero_server_primitives::admin::{
17    AliasKind, CreateAliasRequest, CreateAliasResponse, CreateApplicationIdAlias,
18    CreateContextIdAlias, CreateContextIdentityAlias, CreateContextRequest, CreateContextResponse,
19    DeleteAliasResponse, DeleteContextResponse, GenerateContextIdentityResponse,
20    GetApplicationResponse, GetContextClientKeysResponse, GetContextIdentitiesResponse,
21    GetContextResponse, GetContextStorageResponse, GetContextsResponse, GetLatestVersionResponse,
22    GetPeersCountResponse, GetProposalApproversResponse, GetProposalResponse, GetProposalsResponse,
23    GrantPermissionResponse, InstallApplicationRequest, InstallApplicationResponse,
24    InstallDevApplicationRequest, InviteSpecializedNodeRequest, InviteSpecializedNodeResponse,
25    InviteToContextOpenInvitationRequest, InviteToContextOpenInvitationResponse,
26    InviteToContextRequest, InviteToContextResponse, JoinContextByOpenInvitationRequest,
27    JoinContextRequest, JoinContextResponse, ListAliasesResponse, ListApplicationsResponse,
28    ListPackagesResponse, ListVersionsResponse, LookupAliasResponse, RevokePermissionResponse,
29    SyncContextResponse, UninstallApplicationResponse, UpdateContextApplicationRequest,
30    UpdateContextApplicationResponse,
31};
32use calimero_server_primitives::blob::{BlobDeleteResponse, BlobInfoResponse, BlobListResponse};
33use calimero_server_primitives::jsonrpc::{Request, Response};
34use eyre::Result;
35use serde::de::DeserializeOwned;
36use serde::Serialize;
37use url::Url;
38
39// Local crate
40use crate::connection::ConnectionInfo;
41use crate::traits::{ClientAuthenticator, ClientStorage};
42
43pub trait UrlFragment: ScopedAlias + AliasKind {
44    const KIND: &'static str;
45
46    fn create(self) -> Self::Value;
47
48    fn scoped(scope: Option<&Self::Scope>) -> Option<&str>;
49}
50
51impl UrlFragment for ContextId {
52    const KIND: &'static str = "context";
53
54    fn create(self) -> Self::Value {
55        CreateContextIdAlias { context_id: self }
56    }
57
58    fn scoped(_: Option<&Self::Scope>) -> Option<&str> {
59        None
60    }
61}
62
63impl UrlFragment for PublicKey {
64    const KIND: &'static str = "identity";
65
66    fn create(self) -> Self::Value {
67        CreateContextIdentityAlias { identity: self }
68    }
69
70    fn scoped(context: Option<&Self::Scope>) -> Option<&str> {
71        context.map(ContextId::as_str)
72    }
73}
74
75impl UrlFragment for ApplicationId {
76    const KIND: &'static str = "application";
77
78    fn create(self) -> Self::Value {
79        CreateApplicationIdAlias {
80            application_id: self,
81        }
82    }
83
84    fn scoped(_: Option<&Self::Scope>) -> Option<&str> {
85        None
86    }
87}
88
89#[derive(Debug, Serialize)]
90pub struct ResolveResponse<T> {
91    alias: Alias<T>,
92    value: Option<ResolveResponseValue<T>>,
93}
94
95#[derive(Debug, Serialize)]
96#[serde(tag = "kind", content = "data")]
97pub enum ResolveResponseValue<T> {
98    Lookup(LookupAliasResponse<T>),
99    Parsed(T),
100}
101
102impl<T> ResolveResponse<T> {
103    pub fn value(&self) -> Option<&T> {
104        match self.value.as_ref()? {
105            ResolveResponseValue::Lookup(value) => value.data.value.as_ref(),
106            ResolveResponseValue::Parsed(value) => Some(value),
107        }
108    }
109
110    pub fn alias(&self) -> &Alias<T> {
111        &self.alias
112    }
113
114    pub fn value_enum(&self) -> Option<&ResolveResponseValue<T>> {
115        self.value.as_ref()
116    }
117}
118
119/// Generic API client that can work with any authenticator and storage implementation
120#[derive(Clone, Debug)]
121pub struct Client<A, S>
122where
123    A: ClientAuthenticator + Clone + Send + Sync,
124    S: ClientStorage + Clone + Send + Sync,
125{
126    connection: ConnectionInfo<A, S>,
127}
128
129impl<A, S> Client<A, S>
130where
131    A: ClientAuthenticator + Clone + Send + Sync,
132    S: ClientStorage + Clone + Send + Sync,
133{
134    pub fn new(connection: ConnectionInfo<A, S>) -> Result<Self> {
135        Ok(Self { connection })
136    }
137
138    pub fn api_url(&self) -> &Url {
139        &self.connection.api_url
140    }
141
142    pub async fn get_application(&self, app_id: &ApplicationId) -> Result<GetApplicationResponse> {
143        let response = self
144            .connection
145            .get(&format!("admin-api/applications/{app_id}"))
146            .await?;
147        Ok(response)
148    }
149
150    pub async fn install_dev_application(
151        &self,
152        request: InstallDevApplicationRequest,
153    ) -> Result<InstallApplicationResponse> {
154        let response = self
155            .connection
156            .post("admin-api/install-dev-application", request)
157            .await?;
158        Ok(response)
159    }
160
161    pub async fn install_application(
162        &self,
163        request: InstallApplicationRequest,
164    ) -> Result<InstallApplicationResponse> {
165        let response = self
166            .connection
167            .post("admin-api/install-application", request)
168            .await?;
169        Ok(response)
170    }
171
172    pub async fn list_applications(&self) -> Result<ListApplicationsResponse> {
173        let response = self.connection.get("admin-api/applications").await?;
174        Ok(response)
175    }
176
177    pub async fn uninstall_application(
178        &self,
179        app_id: &ApplicationId,
180    ) -> Result<UninstallApplicationResponse> {
181        let response = self
182            .connection
183            .delete(&format!("admin-api/applications/{app_id}"))
184            .await?;
185        Ok(response)
186    }
187
188    pub async fn delete_blob(&self, blob_id: &BlobId) -> Result<BlobDeleteResponse> {
189        let response = self
190            .connection
191            .delete(&format!("admin-api/blobs/{blob_id}"))
192            .await?;
193        Ok(response)
194    }
195
196    pub async fn list_blobs(&self) -> Result<BlobListResponse> {
197        let response = self.connection.get("admin-api/blobs").await?;
198        Ok(response)
199    }
200
201    pub async fn get_blob_info(&self, blob_id: &BlobId) -> Result<BlobInfoResponse> {
202        let headers = self
203            .connection
204            .head(&format!("admin-api/blobs/{blob_id}"))
205            .await?;
206
207        let size = headers
208            .get("content-length")
209            .and_then(|h| h.to_str().ok())
210            .and_then(|s| s.parse::<u64>().ok())
211            .unwrap_or(0);
212
213        let mime_type = headers
214            .get("content-type")
215            .and_then(|h| h.to_str().ok())
216            .unwrap_or("application/octet-stream")
217            .to_owned();
218
219        let hash_hex = headers
220            .get("x-blob-hash")
221            .and_then(|h| h.to_str().ok())
222            .unwrap_or("");
223
224        let hash =
225            hex::decode(hash_hex).map_err(|_| eyre::eyre!("Invalid hash in response headers"))?;
226
227        let hash_array: [u8; 32] = hash
228            .try_into()
229            .map_err(|_| eyre::eyre!("Hash must be 32 bytes"))?;
230
231        let blob_info = BlobInfoResponse {
232            data: BlobMetadata {
233                blob_id: *blob_id,
234                size,
235                mime_type,
236                hash: hash_array,
237            },
238        };
239
240        Ok(blob_info)
241    }
242
243    pub async fn upload_blob(
244        &self,
245        data: Vec<u8>,
246        context_id: Option<&ContextId>,
247    ) -> Result<BlobInfo> {
248        let path = if let Some(ctx_id) = context_id {
249            format!("admin-api/blobs?context_id={}", ctx_id)
250        } else {
251            "admin-api/blobs".to_owned()
252        };
253
254        let response = self.connection.put_binary(&path, data).await?;
255
256        #[derive(serde::Deserialize)]
257        struct BlobUploadResponse {
258            data: BlobInfo,
259        }
260
261        let upload_response: BlobUploadResponse = response.json().await?;
262        Ok(upload_response.data)
263    }
264
265    pub async fn download_blob(
266        &self,
267        blob_id: &BlobId,
268        context_id: Option<&ContextId>,
269    ) -> Result<Vec<u8>> {
270        let path = if let Some(ctx_id) = context_id {
271            format!("admin-api/blobs/{}?context_id={}", blob_id, ctx_id)
272        } else {
273            format!("admin-api/blobs/{}", blob_id)
274        };
275
276        let data = self.connection.get_binary(&path).await?;
277        Ok(data)
278    }
279
280    pub async fn generate_context_identity(&self) -> Result<GenerateContextIdentityResponse> {
281        let response = self
282            .connection
283            .post("admin-api/identity/context", ())
284            .await?;
285        Ok(response)
286    }
287
288    pub async fn get_peers_count(&self) -> Result<GetPeersCountResponse> {
289        let response = self.connection.get("admin-api/peers").await?;
290        Ok(response)
291    }
292
293    pub async fn execute_jsonrpc<P>(&self, request: Request<P>) -> Result<Response>
294    where
295        P: Serialize,
296    {
297        // Debug: Print the request being sent
298        eprintln!(
299            "🔍 JSON-RPC Request to {}: {}",
300            self.connection.api_url.join("jsonrpc")?,
301            serde_json::to_string_pretty(&request)?
302        );
303
304        let response = self.connection.post("jsonrpc", request).await?;
305
306        // Debug: Print the parsed response
307        eprintln!(
308            "🔍 JSON-RPC Parsed Response: {}",
309            serde_json::to_string_pretty(&response)?
310        );
311
312        Ok(response)
313    }
314
315    pub async fn grant_permissions(
316        &self,
317        context_id: &ContextId,
318        request: Vec<(PublicKey, calimero_context_config::types::Capability)>,
319    ) -> Result<GrantPermissionResponse> {
320        let response = self
321            .connection
322            .post(
323                &format!("admin-api/contexts/{}/capabilities/grant", context_id),
324                request,
325            )
326            .await?;
327        Ok(response)
328    }
329
330    pub async fn revoke_permissions(
331        &self,
332        context_id: &ContextId,
333        request: Vec<(PublicKey, calimero_context_config::types::Capability)>,
334    ) -> Result<RevokePermissionResponse> {
335        let response = self
336            .connection
337            .post(
338                &format!("admin-api/contexts/{}/capabilities/revoke", context_id),
339                request,
340            )
341            .await?;
342        Ok(response)
343    }
344
345    pub async fn invite_to_context(
346        &self,
347        request: InviteToContextRequest,
348    ) -> Result<InviteToContextResponse> {
349        let response = self
350            .connection
351            .post("admin-api/contexts/invite", request)
352            .await?;
353        Ok(response)
354    }
355
356    pub async fn invite_to_context_by_open_invitation(
357        &self,
358        request: InviteToContextOpenInvitationRequest,
359    ) -> Result<InviteToContextOpenInvitationResponse> {
360        let response = self
361            .connection
362            .post("admin-api/contexts/invite_by_open_invitation", request)
363            .await?;
364        Ok(response)
365    }
366
367    /// Invite specialized nodes (e.g., read-only TEE nodes) to join a context.
368    ///
369    /// This broadcasts a specialized node discovery request to the global invite topic.
370    /// Specialized nodes listening will respond with verification and receive invitations.
371    pub async fn invite_specialized_node(
372        &self,
373        request: InviteSpecializedNodeRequest,
374    ) -> Result<InviteSpecializedNodeResponse> {
375        let response = self
376            .connection
377            .post("admin-api/contexts/invite-specialized-node", request)
378            .await?;
379        Ok(response)
380    }
381
382    pub async fn update_context_application(
383        &self,
384        context_id: &ContextId,
385        request: UpdateContextApplicationRequest,
386    ) -> Result<UpdateContextApplicationResponse> {
387        let response = self
388            .connection
389            .post(
390                &format!("admin-api/contexts/{context_id}/application"),
391                request,
392            )
393            .await?;
394        Ok(response)
395    }
396
397    pub async fn get_proposal(
398        &self,
399        context_id: &ContextId,
400        proposal_id: &Hash,
401    ) -> Result<GetProposalResponse> {
402        let response = self
403            .connection
404            .get(&format!(
405                "admin-api/contexts/{}/proposals/{}",
406                context_id, proposal_id
407            ))
408            .await?;
409        Ok(response)
410    }
411
412    pub async fn get_proposal_approvers(
413        &self,
414        context_id: &ContextId,
415        proposal_id: &Hash,
416    ) -> Result<GetProposalApproversResponse> {
417        let response = self
418            .connection
419            .get(&format!(
420                "admin-api/contexts/{}/proposals/{}/approvals/users",
421                context_id, proposal_id
422            ))
423            .await?;
424        Ok(response)
425    }
426
427    pub async fn create_and_approve_proposal(
428        &self,
429        context_id: &ContextId,
430        request: calimero_server_primitives::admin::CreateAndApproveProposalRequest,
431    ) -> Result<calimero_server_primitives::admin::CreateAndApproveProposalResponse> {
432        let response = self
433            .connection
434            .post(
435                &format!("admin-api/contexts/{context_id}/proposals/create-and-approve"),
436                request,
437            )
438            .await?;
439        Ok(response)
440    }
441
442    pub async fn approve_proposal(
443        &self,
444        context_id: &ContextId,
445        request: calimero_server_primitives::admin::ApproveProposalRequest,
446    ) -> Result<calimero_server_primitives::admin::ApproveProposalResponse> {
447        let response = self
448            .connection
449            .post(
450                &format!("admin-api/contexts/{context_id}/proposals/approve"),
451                request,
452            )
453            .await?;
454        Ok(response)
455    }
456
457    pub async fn list_proposals(
458        &self,
459        context_id: &ContextId,
460        args: serde_json::Value,
461    ) -> Result<GetProposalsResponse> {
462        let response = self
463            .connection
464            .post(&format!("admin-api/contexts/{context_id}/proposals"), args)
465            .await?;
466        Ok(response)
467    }
468
469    pub async fn get_context(&self, context_id: &ContextId) -> Result<GetContextResponse> {
470        let response = self
471            .connection
472            .get(&format!("admin-api/contexts/{context_id}"))
473            .await?;
474        Ok(response)
475    }
476
477    pub async fn list_contexts(&self) -> Result<GetContextsResponse> {
478        let response = self.connection.get("admin-api/contexts").await?;
479        Ok(response)
480    }
481
482    pub async fn create_context(
483        &self,
484        request: CreateContextRequest,
485    ) -> Result<CreateContextResponse> {
486        let response = self.connection.post("admin-api/contexts", request).await?;
487        Ok(response)
488    }
489
490    pub async fn delete_context(&self, context_id: &ContextId) -> Result<DeleteContextResponse> {
491        let response = self
492            .connection
493            .delete(&format!("admin-api/contexts/{context_id}"))
494            .await?;
495        Ok(response)
496    }
497
498    pub async fn join_context(&self, request: JoinContextRequest) -> Result<JoinContextResponse> {
499        let response = self
500            .connection
501            .post("admin-api/contexts/join", request)
502            .await?;
503        Ok(response)
504    }
505
506    pub async fn join_context_by_open_invitation(
507        &self,
508        request: JoinContextByOpenInvitationRequest,
509    ) -> Result<JoinContextResponse> {
510        let response = self
511            .connection
512            .post("admin-api/contexts/join_by_open_invitation", request)
513            .await?;
514        Ok(response)
515    }
516
517    pub async fn get_context_storage(
518        &self,
519        context_id: &ContextId,
520    ) -> Result<GetContextStorageResponse> {
521        let response = self
522            .connection
523            .get(&format!("admin-api/contexts/{context_id}/storage"))
524            .await?;
525        Ok(response)
526    }
527
528    pub async fn get_context_identities(
529        &self,
530        context_id: &ContextId,
531        owned: bool,
532    ) -> Result<GetContextIdentitiesResponse> {
533        let endpoint = if owned {
534            format!("admin-api/contexts/{}/identities-owned", context_id)
535        } else {
536            format!("admin-api/contexts/{}/identities", context_id)
537        };
538
539        let response = self.connection.get(&endpoint).await?;
540        Ok(response)
541    }
542
543    pub async fn get_context_client_keys(
544        &self,
545        context_id: &ContextId,
546    ) -> Result<GetContextClientKeysResponse> {
547        let response = self
548            .connection
549            .get(&format!("admin-api/contexts/{context_id}/client-keys"))
550            .await?;
551        Ok(response)
552    }
553
554    pub async fn sync_context(&self, context_id: &ContextId) -> Result<SyncContextResponse> {
555        let response = self
556            .connection
557            .post_no_body(&format!("admin-api/contexts/sync/{context_id}"))
558            .await?;
559        Ok(response)
560    }
561
562    /// Sync all contexts (legacy method for backward compatibility)
563    pub async fn sync_all_contexts(&self) -> Result<SyncContextResponse> {
564        let response = self
565            .connection
566            .post_no_body("admin-api/contexts/sync")
567            .await?;
568        Ok(response)
569    }
570
571    /// Create context identity alias (legacy method for backward compatibility)
572    pub async fn create_context_identity_alias(
573        &self,
574        context_id: &ContextId,
575        request: CreateAliasRequest<PublicKey>,
576    ) -> Result<CreateAliasResponse> {
577        let response = self
578            .connection
579            .post(
580                &format!("admin-api/alias/create/identity/{}", context_id),
581                request,
582            )
583            .await?;
584        Ok(response)
585    }
586
587    /// Create alias generic (legacy method for backward compatibility)
588    pub async fn create_alias_generic<T>(
589        &self,
590        alias: Alias<T>,
591        scope: Option<T::Scope>,
592        value: T,
593    ) -> Result<CreateAliasResponse>
594    where
595        T: UrlFragment + Serialize,
596        T::Value: Serialize,
597    {
598        self.create_alias(alias, value, scope).await
599    }
600
601    pub async fn create_alias<T>(
602        &self,
603        alias: Alias<T>,
604        value: T,
605        scope: Option<T::Scope>,
606    ) -> Result<CreateAliasResponse>
607    where
608        T: UrlFragment + Serialize,
609        T::Value: Serialize,
610    {
611        let prefix = "admin-api/alias/create";
612        let kind = T::KIND;
613        let scope_path = T::scoped(scope.as_ref())
614            .map(|scope| format!("/{}", scope))
615            .unwrap_or_default();
616
617        let body = CreateAliasRequest {
618            alias,
619            value: value.create(),
620        };
621
622        let response = self
623            .connection
624            .post(&format!("{prefix}/{kind}{scope_path}"), body)
625            .await?;
626        Ok(response)
627    }
628
629    pub async fn delete_alias<T>(
630        &self,
631        alias: Alias<T>,
632        scope: Option<T::Scope>,
633    ) -> Result<DeleteAliasResponse>
634    where
635        T: UrlFragment,
636    {
637        let prefix = "admin-api/alias/delete";
638        let kind = T::KIND;
639        let scope_path = T::scoped(scope.as_ref())
640            .map(|scope| format!("/{}", scope))
641            .unwrap_or_default();
642
643        let response = self
644            .connection
645            .post_no_body(&format!("{prefix}/{kind}{scope_path}/{alias}"))
646            .await?;
647        Ok(response)
648    }
649
650    pub async fn list_aliases<T>(&self, scope: Option<T::Scope>) -> Result<ListAliasesResponse<T>>
651    where
652        T: Ord + UrlFragment + DeserializeOwned,
653    {
654        let prefix = "admin-api/alias/list";
655        let kind = T::KIND;
656        let scope_path = T::scoped(scope.as_ref())
657            .map(|scope| format!("/{}", scope))
658            .unwrap_or_default();
659
660        let response = self
661            .connection
662            .get(&format!("{prefix}/{kind}{scope_path}"))
663            .await?;
664        Ok(response)
665    }
666
667    pub async fn lookup_alias<T>(
668        &self,
669        alias: Alias<T>,
670        scope: Option<T::Scope>,
671    ) -> Result<LookupAliasResponse<T>>
672    where
673        T: UrlFragment + DeserializeOwned,
674    {
675        let prefix = "admin-api/alias/lookup";
676        let kind = T::KIND;
677        let scope_path = T::scoped(scope.as_ref())
678            .map(|scope| format!("/{}", scope))
679            .unwrap_or_default();
680
681        let response = self
682            .connection
683            .post_no_body(&format!("{prefix}/{kind}{scope_path}/{alias}"))
684            .await?;
685        Ok(response)
686    }
687
688    pub async fn resolve_alias<T>(
689        &self,
690        alias: Alias<T>,
691        scope: Option<T::Scope>,
692    ) -> Result<ResolveResponse<T>>
693    where
694        T: UrlFragment + FromStr + DeserializeOwned,
695    {
696        let value = self.lookup_alias(alias, scope).await?;
697
698        if value.data.value.is_some() {
699            return Ok(ResolveResponse {
700                alias,
701                value: Some(ResolveResponseValue::Lookup(value)),
702            });
703        }
704
705        let value = alias
706            .as_str()
707            .parse()
708            .ok()
709            .map(ResolveResponseValue::Parsed);
710
711        Ok(ResolveResponse { alias, value })
712    }
713
714    // Package management methods
715    pub async fn list_packages(&self) -> Result<ListPackagesResponse> {
716        let response = self.connection.get("admin-api/packages").await?;
717        Ok(response)
718    }
719
720    pub async fn list_versions(&self, package: &str) -> Result<ListVersionsResponse> {
721        let response = self
722            .connection
723            .get(&format!("admin-api/packages/{package}/versions"))
724            .await?;
725        Ok(response)
726    }
727
728    pub async fn get_latest_version(&self, package: &str) -> Result<GetLatestVersionResponse> {
729        let response = self
730            .connection
731            .get(&format!("admin-api/packages/{package}/latest"))
732            .await?;
733        Ok(response)
734    }
735}