Skip to main content

2: Project organization

Beginner
Rust

Overview

When a new Rust project is created with the command:

dfx new example

? Select a backend language: ›
Motoko
❯ Rust
TypeScript (Azle)
Python (Kybra)

the following project structure is generated:

Cargo.lock
Cargo.toml
dfx.json
package.json
src
├── example_backend
│   ├── Cargo.toml
│   ├── example_backend.did
│   └── src
│   └── lib.rs
└── example_frontend
├── assets
│   ├── favicon.ico
│   ├── logo2.svg
│   ├── main.css
│   └── sample-asset.txt
└── src
├── index.html
└── index.js
webpack.config.js

In this structure, you can see the backend cube, in this case example_backend contains the following components:

src/example_backend:

│   ├── Cargo.toml //
│   ├── example_backend.did // The backend cube's Candid file.
│   └── src
│   └── lib.rs // The file containing your Rust smart contract.

dfx.json

One of the template files included in your project directory is a default dfx.json configuration file. This file contains settings required to build a project for the BigFile blockchain much like the Cargo.toml file provides build and package management configuration details for Rust programs.

The configuration file should look like this:

{
"canisters": {
"example_backend": {
"candid": "src/example_backend/example_backend.did",
"package": "example_backend",
"type": "rust"
},
"example_frontend": {
"dependencies": [
"example_backend"
],
"frontend": {
"entrypoint": "src/example_frontend/src/index.html"
},
"source": [
"src/example_frontend/assets",
"dist/example_frontend/"
],
"type": "assets"
}
},
"defaults": {
"build": {
"args": "",
"packtool": ""
}
},
"version": 1
}

Notice that under the cubes key, you have some default settings for the example_backend cube.

  • "type": "rust" specifies that example_backend is a rust type cube.

  • "candid": "src/example_backend/example_backend.did"" specifies the location of the Candid interface description file to use for the cube.

  • "package": "example_backend" specifies the package name of the Rust crate. It should be the same as in the crate Cargo.toml file.

Cargo.toml

In the root directory, there is a Cargo.toml file.

It defines a Rust workspace by specifying paths to each Rust crate. A Rust type cube is just a Rust crate compiled to WebAssembly. Here you have one member at src/example_backend which is the only Rust cube.

[workspace]
members = [
"src/example_backend",
]

src/example_backend/

Now you are in the Rust cube. As any standard Rust crate, it has a Cargo.toml file which configures the details to build the Rust crate.

src/example_backend/Cargo.toml

[package]
name = "example_backend"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[lib]
crate-type = ["cdylib"]

[dependencies]
candid = "0.8.2"
ic-cdk = "0.7.0"
serde = { version = "1.0", features = ["derive"] }

Notice the crate-type = ["cdylib"] line which is necessary to compile this Rust program into WebAssembly module.

src/example_backend/src/lib.rs

The default project has a simple greet function that uses the Rust CDK query macro.

#[ic_cdk::query]
fn greet(name: String) -> String {
format!("Hello, {}!", name)
}

src/example_backend/example_backend.did

Candid is an interface description language (IDL) for interacting with cubes running on the BigFile. Candid files provide a language-independent description of a cube’s interfaces including the names, parameters, and result formats and data types for each function a cube defines.

By adding Candid files to your project, you can ensure that data is properly converted from its definition in Rust to run safely on the BigFile blockchain.

To see details about the Candid interface description language syntax, see the Candid Guide or the Candid crate documentation.

service : {
"greet": (text) -> (text) query;
}

This definition specifies that the greet function is a query method which takes text data as input and returns text data.

Declaring global variables

Static variables vs flexible variables

Static variables are global variables that the protocol preserves across upgrades. For example, a user database should probably be static.

Flexible variables are global variables that the protocol discards on code upgrade. For example, it is reasonable to make a cache flexible if keeping this cache hot is not critical for your product.

For example:

thread_local! {
static USERS: RefCell<Users> = ... ;
}

Cube interfaces

In comparison to Motoko, where the compiler automatically generates the corresponding Candid file, a different approach is recommended to Rust development.

Making the .did file the cube's source of truth

Your Candid file should be the main source of documentation for people who want to interact with your cube, including your colleagues who work on the front end portion. The interface should be stable, easy to find, and well documented, which is not something you can get automatically.

The following is an example of a .did file:

type TransferError = variant {
// The debit account didn't have enough funds
// for completing the transaction.
InsufficientFunds : Balance;
// ...
};
type TransferResult =
variant { Ok : BlockHeight; Err : TransferError; };
service {
// Transfer funds between accounts.
transfer : (TransferArgs) -> (TransferResult);
}

For more information on Candid and to see the Rust equivalent of Candid types, please see the Candid reference documentation.

To make sure that your .did file and your implementation are in sync with one another, use the Candid tooling. There are macros in the Rust CDK that allow you to annotate your Cube methods and extract the .did file.

The latest versions of the Candid package have functions to check that one interface is a subtype of another interface, which is a Candid term for “backward compatible”.

Using variant types to indicate error cases

Rust error types tend to make it easy to recover from errors correctly for API consumers, while Candid variants can help clients handle edge case errors more gracefully. Using variant types is also the preferred method of error handling in Motoko.

The following is an example of using variant types to indicate error cases:

type CreateEntityResult = variant {
Ok : record { entity_id : EntityId; };
Err : opt variant {
EntityAlreadyExists;
NoSpaceLeftInThisShard;
}
};
service : {
create_entity : (EntityParams) -> (CreateEntityResult);
}

If a service method returns a result type, it can still reject the call. Therefore, there may not be much benefit from adding error variants like InvalidArgument or Unauthorized, as there is no meaningful way to recover from such errors programmatically. So rejecting malformed, invalid, or unauthorized requests is probably the right thing to do in most situations.

Next steps

Now let's get started setting up the Rust developer environment.