Client API
Client API
Overview
The ic-dbms-client crate provides type-safe Rust clients for interacting with ic-dbms canisters. Instead of manually constructing Candid calls, you use a high-level API that handles serialization and error handling.
Benefits:
- Type-safe operations with compile-time checking
- Automatic Candid encoding/decoding
- Consistent API across different environments
- Built-in error handling
Client Types
ic-dbms provides three client implementations for different use cases:
| Client | Use Case | Feature Flag |
|---|---|---|
IcDbmsCanisterClient |
Inter-canister calls (inside IC canisters) | Default |
IcDbmsAgentClient |
External applications (frontend, backend, CLI) | ic-agent |
IcDbmsPocketIcClient |
Integration tests with PocketIC | pocket-ic |
IcDbmsCanisterClient
For calls from one IC canister to another:
use ic_dbms_client::{IcDbmsCanisterClient, Client as _};
use candid::Principal;
// In your canister code
let dbms_canister_id = Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap();
let client = IcDbmsCanisterClient::new(dbms_canister_id);
// Use the client
let users = client.select::<User>(User::table_name(), query, None).await??;
IcDbmsAgentClient
For external applications using the IC Agent:
use ic_dbms_client::{IcDbmsAgentClient, Client as _};
use ic_agent::Agent;
use candid::Principal;
// Create an IC Agent (with identity, etc.)
let agent = Agent::builder()
.with_url("https://ic0.app")
.with_identity(identity)
.build()?;
agent.fetch_root_key().await?; // Only needed for local replica
let dbms_canister_id = Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap();
let client = IcDbmsAgentClient::new(dbms_canister_id, &agent);
// Use the client
let users = client.select::<User>(User::table_name(), query, None).await??;
IcDbmsPocketIcClient
For integration tests using PocketIC:
use ic_dbms_client::{IcDbmsPocketIcClient, Client as _};
use pocket_ic::PocketIc;
use candid::Principal;
let pic = PocketIc::new();
// ... setup canister ...
let client = IcDbmsPocketIcClient::new(
canister_id,
caller_principal, // The principal making calls
&pic
);
// Use the client in tests
let users = client.select::<User>(User::table_name(), query, None).await??;
Installation
Add ic-dbms-client to your Cargo.toml:
For canister development (inter-canister calls):
[dependencies]
ic-dbms-client = "0.4"
For external applications:
[dependencies]
ic-dbms-client = { version = "0.4", features = ["ic-agent"] }
For integration tests:
[dev-dependencies]
ic-dbms-client = { version = "0.4", features = ["pocket-ic"] }
The Client Trait
All clients implement the Client trait, providing a consistent API:
pub trait Client {
// CRUD Operations
async fn insert<T: Table>(&self, table: &str, record: T::InsertRequest, tx: Option<u64>) -> Result<Result<(), IcDbmsError>>;
async fn select<T: Table>(&self, table: &str, query: Query<T>, tx: Option<u64>) -> Result<Result<Vec<T::Record>, IcDbmsError>>;
async fn update<T: Table>(&self, table: &str, update: T::UpdateRequest, tx: Option<u64>) -> Result<Result<u64, IcDbmsError>>;
async fn delete<T: Table>(&self, table: &str, behavior: DeleteBehavior, filter: Option<Filter>, tx: Option<u64>) -> Result<Result<u64, IcDbmsError>>;
// Transactions
async fn begin_transaction(&self) -> Result<u64>;
async fn commit(&self, tx: u64) -> Result<Result<(), IcDbmsError>>;
async fn rollback(&self, tx: u64) -> Result<Result<(), IcDbmsError>>;
// ACL Management
async fn acl_add_principal(&self, principal: Principal) -> Result<Result<(), IcDbmsError>>;
async fn acl_remove_principal(&self, principal: Principal) -> Result<Result<(), IcDbmsError>>;
async fn acl_allowed_principals(&self) -> Result<Vec<Principal>>;
}
Note the double Result:
- Outer
Result: Network/communication errors - Inner
Result: Business logic errors (IcDbmsError)
Operations
Insert
use ic_dbms_client::Client as _;
use my_schema::{User, UserInsertRequest};
let user = UserInsertRequest {
id: 1.into(),
name: "Alice".into(),
email: "alice@example.com".into(),
};
// Without transaction
client.insert::<User>(User::table_name(), user, None).await??;
// With transaction
client.insert::<User>(User::table_name(), user, Some(tx_id)).await??;
Select
use ic_dbms_api::prelude::*;
// Select all
let query = Query::builder().all().build();
let users: Vec<UserRecord> = client
.select::<User>(User::table_name(), query, None)
.await??;
// Select with filter
let query = Query::builder()
.filter(Filter::eq("status", Value::Text("active".into())))
.order_by("created_at", OrderDirection::Descending)
.limit(10)
.build();
let users = client.select::<User>(User::table_name(), query, None).await??;
Update
use my_schema::UserUpdateRequest;
let update = UserUpdateRequest::builder()
.set_email("new@example.com".into())
.filter(Filter::eq("id", Value::Uint32(1.into())))
.build();
let affected_rows: u64 = client
.update::<User>(User::table_name(), update, None)
.await??;
Delete
use ic_dbms_api::prelude::DeleteBehavior;
// Delete with filter
let deleted: u64 = client
.delete::<User>(
User::table_name(),
DeleteBehavior::Restrict,
Some(Filter::eq("id", Value::Uint32(1.into()))),
None
)
.await??;
// Delete all (be careful!)
let deleted: u64 = client
.delete::<User>(
User::table_name(),
DeleteBehavior::Cascade,
None, // No filter = all records
None
)
.await??;
Transactions
// Begin transaction
let tx_id = client.begin_transaction().await?;
// Perform operations
client.insert::<User>(User::table_name(), user1, Some(tx_id)).await??;
client.insert::<User>(User::table_name(), user2, Some(tx_id)).await??;
// Commit or rollback
match some_condition {
true => client.commit(tx_id).await??,
false => client.rollback(tx_id).await??,
}
ACL Management
use candid::Principal;
// Add principal
let new_principal = Principal::from_text("aaaaa-aa").unwrap();
client.acl_add_principal(new_principal).await??;
// Remove principal
client.acl_remove_principal(new_principal).await??;
// List principals
let allowed = client.acl_allowed_principals().await?;
for p in allowed {
println!("Allowed: {}", p);
}
Error Handling
Client operations return nested Results:
// Full error handling
match client.insert::<User>(User::table_name(), user, None).await {
Ok(Ok(())) => {
println!("Insert successful");
}
Ok(Err(db_error)) => {
// Database error (validation, constraint violation, etc.)
match db_error {
IcDbmsError::Query(QueryError::PrimaryKeyConflict) => {
println!("User with this ID already exists");
}
IcDbmsError::Validation(msg) => {
println!("Validation failed: {}", msg);
}
_ => println!("Database error: {:?}", db_error),
}
}
Err(call_error) => {
// Network/canister call error
println!("Call failed: {:?}", call_error);
}
}
Simplified with ??:
// Propagate both error types
client.insert::<User>(User::table_name(), user, None).await??;
Examples
Inter-Canister Communication
A backend canister calling the database canister:
use ic_cdk::update;
use ic_dbms_client::{IcDbmsCanisterClient, Client as _};
use candid::Principal;
const DBMS_CANISTER: &str = "rrkah-fqaaa-aaaaa-aaaaq-cai";
#[update]
async fn create_user(name: String, email: String) -> Result<u32, String> {
let client = IcDbmsCanisterClient::new(
Principal::from_text(DBMS_CANISTER).unwrap()
);
let user_id = generate_id();
let user = UserInsertRequest {
id: user_id.into(),
name: name.into(),
email: email.into(),
};
client
.insert::<User>(User::table_name(), user, None)
.await
.map_err(|e| format!("Call failed: {:?}", e))?
.map_err(|e| format!("Insert failed: {:?}", e))?;
Ok(user_id)
}
#[update]
async fn get_users() -> Result<Vec<UserRecord>, String> {
let client = IcDbmsCanisterClient::new(
Principal::from_text(DBMS_CANISTER).unwrap()
);
let query = Query::builder().all().build();
client
.select::<User>(User::table_name(), query, None)
.await
.map_err(|e| format!("Call failed: {:?}", e))?
.map_err(|e| format!("Query failed: {:?}", e))
}
External Application
A CLI tool or backend service:
use ic_agent::{Agent, identity::BasicIdentity};
use ic_dbms_client::{IcDbmsAgentClient, Client as _};
use candid::Principal;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Load identity from PEM file
let identity = BasicIdentity::from_pem_file("identity.pem")?;
// Create agent
let agent = Agent::builder()
.with_url("https://ic0.app")
.with_identity(identity)
.build()?;
// For local development, fetch root key
// agent.fetch_root_key().await?;
let canister_id = Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai")?;
let client = IcDbmsAgentClient::new(canister_id, &agent);
// List all users
let query = Query::builder().all().build();
let users = client.select::<User>(User::table_name(), query, None).await??;
for user in users {
println!("User: {} ({})", user.name, user.email);
}
Ok(())
}
Integration Testing
Testing with PocketIC:
use ic_dbms_client::{IcDbmsPocketIcClient, Client as _};
use pocket_ic::PocketIc;
use candid::{encode_one, Principal};
#[tokio::test]
async fn test_user_crud() {
// Setup PocketIC
let pic = PocketIc::new();
// Create and install canister
let canister_id = pic.create_canister();
pic.add_cycles(canister_id, 2_000_000_000_000);
let wasm = std::fs::read("path/to/canister.wasm").unwrap();
let init_args = IcDbmsCanisterArgs::Init(IcDbmsCanisterInitArgs {
allowed_principals: vec![admin_principal],
});
pic.install_canister(
canister_id,
wasm,
encode_one(init_args).unwrap(),
None
);
// Create client
let client = IcDbmsPocketIcClient::new(canister_id, admin_principal, &pic);
// Test insert
let user = UserInsertRequest {
id: 1.into(),
name: "Test User".into(),
email: "test@example.com".into(),
};
client.insert::<User>(User::table_name(), user, None).await.unwrap().unwrap();
// Test select
let query = Query::builder().all().build();
let users = client.select::<User>(User::table_name(), query, None).await.unwrap().unwrap();
assert_eq!(users.len(), 1);
assert_eq!(users[0].name.as_str(), "Test User");
// Test update
let update = UserUpdateRequest::builder()
.set_name("Updated User".into())
.filter(Filter::eq("id", Value::Uint32(1.into())))
.build();
let affected = client.update::<User>(User::table_name(), update, None).await.unwrap().unwrap();
assert_eq!(affected, 1);
// Test delete
let deleted = client.delete::<User>(
User::table_name(),
DeleteBehavior::Restrict,
Some(Filter::eq("id", Value::Uint32(1.into()))),
None
).await.unwrap().unwrap();
assert_eq!(deleted, 1);
// Verify deletion
let users = client.select::<User>(User::table_name(), Query::builder().all().build(), None).await.unwrap().unwrap();
assert_eq!(users.len(), 0);
}