progress
BROWSE CHAPTERS
Constructing the Messaging Canister
In this section, we're going to write our messaging canister. This canister is designed to handle the fundamental CRUD (Create, Read, Update, and Delete) operations, which are key to the functioning of any data-driven application. This functionality enables efficient data management within the canister. More specifically, we're going to use Azle to build a simple message board application, which will allow users to create, update, delete, and view messages.
If you're familiar with TypeScript, you'll find the Azle syntax quite similar. But even if you're new to TypeScript, there's no need to worry - we'll be guiding you through the syntax as we proceed with the development.
Setting Up the Directory and Entry Point
After cloning the boilerplate code, we would see a folder called src
with a file called index.ts
. This would be the
entrypoint of our canister and will contain our logic.
Importing Dependencies
To start, we need to incorporate several dependencies which our smart contract will make use of. Add the following lines
of code at the top of your index.ts
file:
Here's a brief rundown of what each of these imported items does:
$query
: is an annotation enabling us to retrieve information from our canister.$update
:is an annotation facilitating updates to our canister.Record
: Type used for creating a record data structure.StableBTreeMap
: Type used for creating a map data structure.Vec
: Type used for creating a vector data structure.match
: Function enabling us to perform pattern matching on a result.Result
: Type used for creating a result data structure.nat64
: Type used for creating a 64-bit unsigned integer.ic
: is an object that provides access to various APIs of the Internet Computer.Opt
: Type used for creating an optional data structure.uuidv4
: Function generating a unique identifier for each new message.
Defining Message Type
Before we start writing the logic of our canister, it's important to define the structure of the data we'll be working
with. In our case, this is the 'Message' that will be posted on the board. This definition will help us ensure
consistency and clarity when dealing with messages in our smart contract. Add the following code in the index.ts
file
below the import statements:
This code block defines the 'Message' type, where each message is characterized by a unique identifier, a title, a body, an attachment URL, and timestamps indicating when the message was created and last updated.
Defining Message Payload Type
After defining the structure of a Message, we need to specify what kind of data will be sent to our smart contract. This is called the payload. In our context, the payload will contain the basic information needed to create a new message.
Add the following code in the index.ts
file below the definition of the message type:
This 'MessagePayload' type outlines the structure of the data that will be sent to our smart contract when a new message is created. Each payload consists of a title, a body, and an attachment URL.
Defining the Message Storage
Now that we've defined our message types, we need a place to store these messages. For this, we'll be creating a storage
variable in our index.ts
file below the definition of the message payload type:
This line of code establishes a storage variable named messageStorage
, which is a map associating strings (our keys)
with messages (our values). This storage will allow us to store and retrieve messages within our canister.
Let's break down the new StableBTreeMap
constructor:
- The first argument
0
signifies the memory id, where to instantiate the map. -
The second argument
44
sets the maximum size of the key (in bytes) in this map, it's 44 bytes because uuid_v4 generates identifiers which are exactly 44 bytes each. -
The third argument
1024
defines the maximum size of each value within the map, ensuring our messages don't exceed a certain size.
Note: it is not compulsory to use the StableBTreeMap. We can choose between using tools from the JavaScript standard library like Map or the StableBTreeMap. While both options have their uses, it's important to highlight the significance of the StableBTreeMap. It offers durability, ensuring data persists across canister redeployments, making it suitable for storing critical and long-term data. On the other hand, the Map from the JavaScript standard library is ideal for temporary data as it is erased during redeployments. You should carefully consider your data persistence needs when deciding which data structure to use.
Creating the Get Messages Function
The next step is to create a function that retrieves all messages stored within our canister. To accomplish this, add
the following code to your index.ts
file below the definition of the message storage:
This getMessages
function gives us access to all messages on our message board. The $query
decorator preceding the
function tells Azle that getMessages
is a query function, meaning it reads from but doesn't alter the state of our
canister.
The function returns a Result
type, which can hold either a value or an error. In this case, we're returning a vector
of messages (Vec<Message>
) on successful execution, or a string error message if something goes wrong."
Note: We do not need to use the Result wrapper to return the response. We use it here just to maintain consistency accross the implementation.
Creating the Get Message Function
The next step involves creating a function to retrieve a specific message using its unique identifier (ID). Add the
following code to your index.ts
file below the getMessages
function:
Here's an in-depth look at what the code does:
-
We start by using the
$query
annotation to indicate that this function is a query function. A query function is one that does not alter the state of our canister. -
The
getMessage
function is defined, which takes a string parameterid
. Thisid
is the unique identifier for the message we wish to retrieve. The function's return type isResult<Message, string>
. This means the function either returns aMessage
object if successful or a string error message if unsuccessful. -
Inside the function, we use the
match
function from Azle. This function is used to handle possible options from a function that may or may not return a result, in our case,messageStorage.get(id)
. messageStorage.get(id)
attempts to retrieve a message with the givenid
from ourmessageStorage
.-
If a message with the given
id
is found, theSome
function is triggered, passing the found message as a parameter. We then return the found message wrapped inResult.Ok
. -
If no message with the given
id
is found, theNone
function is triggered. We return an error message wrapped inResult.Err
stating that no message with the givenid
was found.
This function, therefore, allows us to specifically query a message by its unique ID. If no message is found for the provided ID, we clearly communicate this by returning an informative error message."
Creating the Add Message Function
Following on, we will create a function to add new messages. Input the following code into your index.ts
file below
the getMessage
function:
Here's a detailed exploration of the key components:
-
The
$update
annotation is utilized to signify to Azle that this function is an update function. It is labelled as such because it modifies the state of our canister. -
The function
addMessage
is defined, which accepts a parameterpayload
of typeMessagePayload
. This payload will contain the data for the new message to be created. -
Inside the function, we generate a new
Message
object. Theid
field of the message is assigned a unique identifier generated by theuuidv4
function. ThecreatedAt
field is assigned the current time retrieved usingic.time()
. TheupdatedAt
field is set toOpt.None
since the message has not been updated at the point of creation. Finally, the remaining fields are spread from thepayload
using the spread operator (...payload
). -
The newly created message is then inserted into the
messageStorage
using theinsert
method. Theid
of the message is used as the key. -
The function concludes by returning the newly created message, wrapped in a
Result.Ok
. If any errors occurred during the process, the function would return a string error message.
This function thus facilitates the creation of new messages within our canister, providing each with a unique identifier and timestamp."
Developing the Update Message Function
Our next step is to create a function that allows us to update an existing message. Insert the following code into your
index.ts
file below the addMessage
function:
This function, denoted by the $update
decorator, will change the state of our canister. Here's a breakdown of the new
elements:
-
The
updateMessage
function takes two parameters:id
, which represents the unique identifier of the message to be updated, andpayload
, which contains the new data for the message. -
Inside the function, we use the
match
function to handle the outcome of retrieving a message frommessageStorage
by itsid
. Thematch
function takes two cases:Some
andNone
. -
In the
Some
case, it implies that a message with the providedid
exists. We create an updated message by spreading the existing message and the payload into a new object, and set theupdatedAt
field with the current time usingic.time()
. This updated message is then inserted back intomessageStorage
using the sameid
. -
In the
None
case, it indicates that no message with the providedid
could be found. In this situation, the function returns an error message stating that the update operation couldn't be performed as the message was not found.
This updateMessage
function thus enables us to update the contents of an existing message within our canister.
Creating a Function to Delete a Message
The final step in our canister development is to create a function that allows for message deletion. Insert the
following code into your index.ts
file below the updateMessage
function:
Here, we're using the messageStorage.remove(id)
method to attempt to remove a message by its ID from our storage. If
the operation is successful, it returns the deleted message, which we wrap in a Result.Ok
and return from the
function. If no message with the given ID exists, the removal operation returns None
, and we return an error message
wrapped in a Result.Err
, notifying that no message could be found with the provided ID to delete.
This function, marked by the $update
decorator, further extends our canister's capabilities, now including message
deletion alongside creation, retrieval, and update.
Configuring the UUID Package
A notable point is that the uuidV4 package may not function correctly within our canister. To address this, we need to
apply a workaround that ensures compatibility with Azle. Insert the following code at the end of your index.ts
file:
In this block of code, we're extending the globalThis
object by adding a crypto
property to it. This property is an
object with a method called getRandomValues
. This method generates an array of random values, which is required by the
uuidV4
function to generate unique IDs. Here's how it works:
-
We create a new
Uint8Array
with 32 elements. Each element is an 8-bit unsigned integer, meaning it can hold a value between 0 and 255. -
We then iterate over this array, assigning each element a random value between 0 and 255. This is achieved by using
Math.random()
to generate a random float between 0 and 1, then multiplying it by 256 and rounding down to the nearest whole number withMath.floor()
. -
Finally, we return this array of random values. This array is used by the
uuidV4
function to create unique IDs for our messages.
By adding this block of code, we ensure that the uuidV4
package works smoothly with the Azle framework within our
canister."
The Final Code
At the end of this step, your index.ts
file should look like this:
Next Chapter
Deploying and Interacting with our Canister