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:

javascript
import { $query, $update, Record, StableBTreeMap, Vec, match, Result, nat64, ic, Opt } from "azle"
import { v4 as uuidv4 } from "uuid"

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:

javascript
/**
 * This type represents a message that can be listed on a board.
 */
 
type Message = Record<{
  id: string,
  title: string,
  body: string,
  attachmentURL: string,
  createdAt: nat64,
  updatedAt: Opt<nat64>,
}>

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:

javascript
type MessagePayload = Record<{
  title: string,
  body: string,
  attachmentURL: string,
}>

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:

typescript
const messageStorage = new StableBTreeMap<string, Message>(0, 44, 1024)

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:

javascript
$query
export function getMessages(): Result<Vec<Message>, string> {
  return Result.Ok(messageStorage.values())
}

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:

javascript
$query;
export function getMessage(id: string): Result<Message, string> {
    return match(messageStorage.get(id), {
        Some: (message) => Result.Ok<Message, string>(message),
        None: () => Result.Err<Message, string>(`a message with id=${id} not found`)
    });
}

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 parameter id. This id is the unique identifier for the message we wish to retrieve. The function's return type is Result<Message, string>. This means the function either returns a Message 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 given id from our messageStorage.
  • If a message with the given id is found, the Some function is triggered, passing the found message as a parameter. We then return the found message wrapped in Result.Ok.
  • If no message with the given id is found, the None function is triggered. We return an error message wrapped in Result.Err stating that no message with the given id 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:

javascript
$update
export function addMessage(payload: MessagePayload): Result<Message, string> {
  const message: Message = { id: uuidv4(), createdAt: ic.time(), updatedAt: Opt.None, ...payload }
  messageStorage.insert(message.id, message)
  return Result.Ok(message)
}

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 parameter payload of type MessagePayload. This payload will contain the data for the new message to be created.

  • Inside the function, we generate a new Message object. The id field of the message is assigned a unique identifier generated by the uuidv4 function. The createdAt field is assigned the current time retrieved using ic.time(). The updatedAt field is set to Opt.None since the message has not been updated at the point of creation. Finally, the remaining fields are spread from the payload using the spread operator (...payload).

  • The newly created message is then inserted into the messageStorage using the insert method. The id 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:

javascript
$update;
export function updateMessage(id: string, payload: MessagePayload): Result<Message, string> {
    return match(messageStorage.get(id), {
        Some: (message) => {
            const updatedMessage: Message = {...message, ...payload, updatedAt: Opt.Some(ic.time())};
            messageStorage.insert(message.id, updatedMessage);
            return Result.Ok<Message, string>(updatedMessage);
        },
        None: () => Result.Err<Message, string>(`couldn't update a message with id=${id}. message not found`)
    });
}

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, and payload, which contains the new data for the message.
  • Inside the function, we use the match function to handle the outcome of retrieving a message from messageStorage by its id. The match function takes two cases: Some and None.
  • In the Some case, it implies that a message with the provided id exists. We create an updated message by spreading the existing message and the payload into a new object, and set the updatedAt field with the current time using ic.time(). This updated message is then inserted back into messageStorage using the same id.
  • In the None case, it indicates that no message with the provided id 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:

javascript
$update;
export function deleteMessage(id: string): Result<Message, string> {
    return match(messageStorage.remove(id), {
        Some: (deletedMessage) => Result.Ok<Message, string>(deletedMessage),
        None: () => Result.Err<Message, string>(`couldn't delete a message with id=${id}. message not found.`)
    });
}

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:

typescript
// a workaround to make uuid package work with Azle
globalThis.crypto = {
  // @ts-ignore
  getRandomValues: () => {
    let array = new Uint8Array(32)
 
    for (let i = 0; i < array.length; i++) {
      array[i] = Math.floor(Math.random() * 256)
    }
 
    return array
  },
}

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 with Math.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:

javascript
import { $query, $update, Record, StableBTreeMap, Vec, match, Result, nat64, ic, Opt } from 'azle';
import { v4 as uuidv4 } from 'uuid';
 
type Message = Record<{
    id: string;
    title: string;
    body: string;
    attachmentURL: string;
    createdAt: nat64;
    updatedAt: Opt<nat64>;
}>
 
type MessagePayload = Record<{
    title: string;
    body: string;
    attachmentURL: string;
}>
 
const messageStorage = new StableBTreeMap<string, Message>(0, 44, 1024);
 
$query;
export function getMessages(): Result<Vec<Message>, string> {
    return Result.Ok(messageStorage.values());
}
 
$query;
export function getMessage(id: string): Result<Message, string> {
    return match(messageStorage.get(id), {
        Some: (message) => Result.Ok<Message, string>(message),
        None: () => Result.Err<Message, string>(`a message with id=${id} not found`)
    });
}
 
$update;
export function addMessage(payload: MessagePayload): Result<Message, string> {
    const message: Message = { id: uuidv4(), createdAt: ic.time(), updatedAt: Opt.None, ...payload };
    messageStorage.insert(message.id, message);
    return Result.Ok(message);
}
 
$update;
export function updateMessage(id: string, payload: MessagePayload): Result<Message, string> {
    return match(messageStorage.get(id), {
        Some: (message) => {
            const updatedMessage: Message = {...message, ...payload, updatedAt: Opt.Some(ic.time())};
            messageStorage.insert(message.id, updatedMessage);
            return Result.Ok<Message, string>(updatedMessage);
        },
        None: () => Result.Err<Message, string>(`couldn't update a message with id=${id}. message not found`)
    });
}
 
$update;
export function deleteMessage(id: string): Result<Message, string> {
    return match(messageStorage.remove(id), {
        Some: (deletedMessage) => Result.Ok<Message, string>(deletedMessage),
        None: () => Result.Err<Message, string>(`couldn't delete a message with id=${id}. message not found.`)
    });
}
 
// a workaround to make uuid package work with Azle
globalThis.crypto = {
     // @ts-ignore
    getRandomValues: () => {
        let array = new Uint8Array(32);
 
        for (let i = 0; i < array.length; i++) {
            array[i] = Math.floor(Math.random() * 256);
        }
 
        return array;
    }
};
ICP CHAT BOT

👋 Welcome! If you need assistance with any part of this tutorial, get stuck or have questions about the material, I'm here to help.