DEV Community

Cover image for Appwrite + Rust: Build APIs without technical overhead
Demola Malomo for Hackmamba

Posted on • Originally published at fullstackwriter.dev

Appwrite + Rust: Build APIs without technical overhead

In every stage of the Sofware Development Lifecycle (SDLC), developers must make strategic decisions around databases, authorization, deployment mechanisms, server sizes, storage management, etc. These decisions must be thoroughly assessed as they can significantly impact the application’s development process.

One paradigm developers constantly embrace is Backend-as-a-Service (BaaS). Baas abstracts the development overhead associated with SDLC and focuses only on the business logic. It provides developers with server-side capabilities like user authentication, database management, cloud storage, etc.

In this post, we will explore leveraging Appwrite as a BaaS by building a project management API in Rust. The API will provide functionalities to create, read, update, and delete a project. The project repository can be found here.

What is Appwrite?

Appwrite is an open-source backend as a service platform that provides sets of APIs and SDKs for building web, mobile, and backend services. The following are some of the benefits of using Appwrite in any application:

  • Provides a scalable and robust database
  • Realtime functionalities
  • Support for serverless functions
  • Security certificates and encryption
  • Authentication and authorization mechanism

Prerequisites

To follow along with this tutorial, the following are needed:

  • Basic understanding of Rust
  • Appwrite account. Signup is free

Getting started

To get started, we need to navigate to the desired directory and run the command below:

cargo new rust-appwrite && cd rust-appwrite
Enter fullscreen mode Exit fullscreen mode

This command creates a Rust project called rust-appwrite and navigates into the project directory.

Next, we proceed to install the required dependencies by modifying the [dependencies] section of the Cargo.toml file as shown below:

[dependencies]
actix-web = "4"
serde = { version = "1.0.145", features = ["derive"] }
serde_json = "1.0"
dotenv = "0.15.0"
reqwest = { version = "0.11", features = ["json"] }
Enter fullscreen mode Exit fullscreen mode

actix-web = "4" is a Rust-based framework for building web applications.

serde = { version = "1.0.145", features = ["derive"] } is a framework for serializing and deserializing Rust data structures.

serde_json = "1.0" is a crate that uses the serde crate to manipulate JSON and vice versa.

reqwest = { version = "0.11", features = ["json"] } is a HTTP request crate.

We need to run the command below to install the dependencies:

cargo build
Enter fullscreen mode Exit fullscreen mode

Structuring the application

It is essential to have a good project structure as it makes the codebase maintainable and seamless for anyone to read or manage.

To do this, we need to navigate to the src directory and, in this folder, create an api folder. In the api folder, we also need to create a mod.rs, models.rs, services.rs, and handlers.rs files.

Updated file structure

mod.rs is a file for managing application visibility.

models.rs is for structuring our application data.

services.rs is for abstracting our application logic.

handlers.rs is for structuring our APIs.

Next, we need to declare these files as a module by importing them into the mod.rs file

pub mod handlers;
pub mod models;
pub mod services;
Enter fullscreen mode Exit fullscreen mode

Finally, we need to register api folder as a parent module by importing it into the main.rs file as shown below:

mod api;

fn main() {
    println!("Hello world")
}
Enter fullscreen mode Exit fullscreen mode

Setting up Appwrite

To get started, we need to log into our Appwrite console, click the Create project button, input api_rust as the name, and then Create.

Create project

Create a Database, Collection, and Add Attributes

Appwrite ships a scalable and robust database that we can use in building our project management API. To do this, first, navigate to the Database tab, click the Create database button, input project as the name, and Create.

Create database

Secondly, we need to create a collection for storing our projects. To do this, click the Create collection button, input project_collection as the name, and then click Create.

Create collection

Lastly, we need to create attributes to represent our database fields. To do this, we need to navigate to the Attributes tab and create attributes for each of the values shown below:

Attribute key Attribute type Size Required
name String 250 YES
description String 5000 YES

Create attribute
create

After creating the attributes, we see them as shown below:

List of attributes

Create an API key

To securely connect to Appwrite, we need to create an API key. To do this, we need to navigate to the Overview tab, scroll to the Integrate With Your Server section, and click the API Key button.

Create API key

Next, input api_rust as the name, click the Next button, select Database as the required scope, and Create.

input  name create
Set permission

Leveraging Appwrite to build the project management APIs in Rust

With our project fully set up on Appwrite, we can now use the database without manually creating a server.

Set up Environment Variable

To securely connect to our Appwrite provisioned server, Appwrite provides an endpoint and sets of unique IDs to perform all the required actions. To set up the required environment variables, we need to create a .env file in the root directory and add the snippet below:

API_KEY=<REPLACE WITH API KEY>
PROJECT_ID=<REPLACE WITH PROJECT ID>
DATABASE_ID=<REPLACE WITH DATABASE ID>
COLLECTION_ID=<REPLACE WITH COLLECTION ID>
Enter fullscreen mode Exit fullscreen mode

We can get the required API key and IDs from our Appwrite console as shown below:

API key

Project ID

Database and Collection ID

Create the API models

Next, we need to create models to represent our application data. To do this, we need to modify the models.rs file as shown below:

use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Project {
    #[serde(rename = "$id")]
    pub id: Option<String>,
    pub name: String,
    pub description: String,
}

#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ProjectRequest {
    pub name: String,
    pub description: String,
}

#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ProjectResponse {
    #[serde(rename = "$id")]
    pub id: String,
    #[serde(rename = "$collectionId")]
    pub collection_id: String,
}

#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct JsonAPIBody {
    pub documentId: Option<String>,
    pub data: ProjectRequest,
}

#[derive(Serialize, Debug, Clone)]
pub struct APIResponse<T> {
    pub status: u16,
    pub message: String,
    pub data: Option<T>,
}

#[derive(Serialize, Debug, Clone)]
pub struct APIErrorResponse {
    pub status: u16,
    pub message: String,
    pub data: Option<String>,
}
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Imports the required dependency
  • Creates a Project, ProjectRequest, ProjectResponse, and JsonAPIBody structs with required properties to describe request and response body accordingly
  • Creates an APIResponse, and APIErrorResponse structs with the required properties needed for the API response

PS: The #[serde(rename = "FieldName")] macro renames the corresponding field to a specified name, and the #[derive()] macro adds implementation support for serialization, deserialization, debugging, and cloning.

Create the API services

With our application models fully set up, we can now use them to create our application logic. To do this, we need to update the service.rs file by doing the following:

First, we need to import the required dependencies, create helper functions, and a method for creating a project:

use dotenv::dotenv;
use reqwest::{header, Client, Error};
use std::env;
use super::model::{JsonAPIBody, Project, ProjectRequest, ProjectResponse};

pub struct AppwriteService {}

impl AppwriteService {
    fn env_loader(key: &str) -> String {
        dotenv().ok();
        match env::var(key) {
            Ok(v) => v.to_string(),
            Err(_) => format!("Error loading env variable"),
        }
    }

    fn init() -> Client {
        Client::new()
    }

    pub async fn create_project(new_project: ProjectRequest) -> Result<ProjectResponse, Error> {
        //get details from environment variable
        let project_id = AppwriteService::env_loader("PROJECT_ID");
        let database_id = AppwriteService::env_loader("DATABASE_ID");
        let collection_id = AppwriteService::env_loader("COLLECTION_ID");
        let api_key = AppwriteService::env_loader("API_KEY");

        let url = format!("https://cloud.appwrite.io/v1/databases/{database_id}/collections/{collection_id}/documents");

        //create header
        let mut headers = header::HeaderMap::new();
        headers.insert("X-Appwrite-Key", api_key.parse().unwrap());
        headers.insert("X-Appwrite-Project", project_id.parse().unwrap());

        let client = AppwriteService::init()
            .post(url)
            .headers(headers)
            .json(&JsonAPIBody {
                documentId: Some("unique()".to_string()),
                data: new_project,
            })
            .send()
            .await;

        match client {
            Ok(response) => {
                let json = response.text().await?;
                let created_project: ProjectResponse = serde_json::from_str(json.as_str()).unwrap();
                Ok(created_project)
            }
            Err(error) => Err(error),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Imports the required dependencies
  • Creates an AppwriteService struct
  • Creates an implementation block that adds env_loader and init helper methods to load environment variables and creates a connection pool for making asynchronous requests
  • Creates a create_project method that uses the helper methods to get the required environment variable, configure the Appwrite’s provisioned server URL, make a request, and return appropriate responses.

PS: The unique() tag specified when creating a project tells Appwrite to autogenerate the project ID.

Secondly, we need to add a get_project method that uses similar logic as the create_project function to get the details of a project.

//imports goes here

pub struct AppwriteService {}

impl AppwriteService {
    //helper method goes here

    pub async fn create_project(new_project: ProjectRequest) -> Result<ProjectResponse, Error> {
        //create_project code goes here
    }

    pub async fn get_project(document_id: String) -> Result<Project, Error> {
        //get details from environment variable
        let project_id = AppwriteService::env_loader("PROJECT_ID");
        let database_id = AppwriteService::env_loader("DATABASE_ID");
        let collection_id = AppwriteService::env_loader("COLLECTION_ID");
        let api_key = AppwriteService::env_loader("API_KEY");

        let url = format!("https://cloud.appwrite.io/v1/databases/{database_id}/collections/{collection_id}/documents/{document_id}");

        //create header
        let mut headers = header::HeaderMap::new();
        headers.insert("X-Appwrite-Key", api_key.parse().unwrap());
        headers.insert("X-Appwrite-Project", project_id.parse().unwrap());

        let client = AppwriteService::init()
            .get(url)
            .headers(headers)
            .send()
            .await;

        match client {
            Ok(response) => {
                let json = response.text().await?;
                let project_detail: Project = serde_json::from_str(json.as_str()).unwrap();
                Ok(project_detail)
            }
            Err(error) => Err(error),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Thirdly, we need to add a update_project method that uses similar logic as the create_project function to update the details of a project.

//imports go here

pub struct AppwriteService {}

impl AppwriteService {
    //helper method goes here

    pub async fn create_project(new_project: ProjectRequest) -> Result<ProjectResponse, Error> {
        //create_project code goes here
    }

    pub async fn get_project(document_id: String) -> Result<Project, Error> {
        //get_project goes here
    }

    pub async fn update_project(
        updated_project: ProjectRequest,
        document_id: String,
    ) -> Result<ProjectResponse, Error> {
        //get details from environment variable
        let project_id = AppwriteService::env_loader("PROJECT_ID");
        let database_id = AppwriteService::env_loader("DATABASE_ID");
        let collection_id = AppwriteService::env_loader("COLLECTION_ID");
        let api_key = AppwriteService::env_loader("API_KEY");

        let url = format!("https://cloud.appwrite.io/v1/databases/{database_id}/collections/{collection_id}/documents/{document_id}");

        //create header
        let mut headers = header::HeaderMap::new();
        headers.insert("X-Appwrite-Key", api_key.parse().unwrap());
        headers.insert("X-Appwrite-Project", project_id.parse().unwrap());

        let client = AppwriteService::init()
            .patch(url)
            .headers(headers)
            .json(&JsonAPIBody {
                documentId: None,
                data: updated_project,
            })
            .send()
            .await;

        match client {
            Ok(response) => {
                let json = response.text().await?;
                let updates: ProjectResponse = serde_json::from_str(json.as_str()).unwrap();
                Ok(updates)
            }
            Err(error) => Err(error),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Lastly, we need to add a delete_project method that uses similar logic as the create_project function to delete the details of a project.

//import goes here

pub struct AppwriteService {}

impl AppwriteService {
    //helper method goes here

    pub async fn create_project(new_project: ProjectRequest) -> Result<ProjectResponse, Error> {
        //create_project code goes here
    }

    pub async fn get_project(document_id: String) -> Result<Project, Error> {
        //get_project goes here
    }

    pub async fn update_project(
        updated_project: ProjectRequest,
        document_id: String,
    ) -> Result<ProjectResponse, Error> {
        //update_project code goes here
    }

    pub async fn delete_project(document_id: String) -> Result<String, Error> {
        //get details from environment variable
        let project_id = AppwriteService::env_loader("PROJECT_ID");
        let database_id = AppwriteService::env_loader("DATABASE_ID");
        let collection_id = AppwriteService::env_loader("COLLECTION_ID");
        let api_key = AppwriteService::env_loader("API_KEY");

        let url = format!("https://cloud.appwrite.io/v1/databases/{database_id}/collections/{collection_id}/documents/{document_id}");

        //create header
        let mut headers = header::HeaderMap::new();
        headers.insert("X-Appwrite-Key", api_key.parse().unwrap());
        headers.insert("X-Appwrite-Project", project_id.parse().unwrap());

        let client = AppwriteService::init()
            .delete(url)
            .headers(headers)
            .send()
            .await;

        match client {
            Ok(_) => {
                let json = format!("Project with ID: ${document_id} deleted successfully!!");
                Ok(json)
            }
            Err(error) => Err(error),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Create the API handlers

With that done, we can use the services to create our API handlers. To do this, first, we need to add the snippet below to the handlers.rs file:

use super::{
    model::{APIErrorResponse, APIResponse, Project, ProjectRequest, ProjectResponse},
    services::AppwriteService,
};
use actix_web::{
    delete, get, patch, post,
    web::{Json, Path},
    HttpResponse,
};
use reqwest::StatusCode;

#[post("/project")]
pub async fn create_project_handler(data: Json<ProjectRequest>) -> HttpResponse {
    let new_project = ProjectRequest {
        name: data.name.clone(),
        description: data.description.clone(),
    };
    let project_details = AppwriteService::create_project(new_project).await;

    match project_details {
        Ok(data) => HttpResponse::Accepted().json(APIResponse::<ProjectResponse> {
            status: StatusCode::CREATED.as_u16(),
            message: "success".to_string(),
            data: Some(data),
        }),
        Err(error) => HttpResponse::InternalServerError().json(APIErrorResponse {
            status: StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
            message: "failure".to_string(),
            data: Some(error.to_string()),
        }),
    }
}

#[get("/project/{id}")]
pub async fn get_project_handler(path: Path<String>) -> HttpResponse {
    let id = path.into_inner();
    if id.is_empty() {
        return HttpResponse::BadRequest().json(APIErrorResponse {
            status: StatusCode::BAD_REQUEST.as_u16(),
            message: "failure".to_string(),
            data: Some("invalid ID".to_string()),
        });
    };
    let project_details = AppwriteService::get_project(id).await;

    match project_details {
        Ok(data) => HttpResponse::Accepted().json(APIResponse::<Project> {
            status: StatusCode::OK.as_u16(),
            message: "success".to_string(),
            data: Some(data),
        }),
        Err(error) => HttpResponse::InternalServerError().json(APIErrorResponse {
            status: StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
            message: "failure".to_string(),
            data: Some(error.to_string()),
        }),
    }
}
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Imports the required dependencies
  • Creates a create_project_handler and get_project_handler handler with corresponding API routes that use the services to perform the corresponding actions and return the appropriate response using the APIResponse and APIErrorResponse

Lastly, we need to add update_project_handler and delete_project_handler handler that uses similar logic as the handlers above to update and delete a project.

//import goes here

#[post("/project")]
pub async fn create_project_handler(data: Json<ProjectRequest>) -> HttpResponse {
    //create_project_handler code goes here
}

#[get("/project/{id}")]
pub async fn get_project_handler(path: Path<String>) -> HttpResponse {
    //get_project_handler code goes here
}

#[patch("/project/{id}")]
pub async fn update_project_handler(
    updated_project: Json<ProjectRequest>,
    path: Path<String>,
) -> HttpResponse {
    let id = path.into_inner();
    if id.is_empty() {
        return HttpResponse::BadRequest().json(APIErrorResponse {
            status: StatusCode::BAD_REQUEST.as_u16(),
            message: "failure".to_string(),
            data: Some("invalid ID".to_string()),
        });
    };
    let data = ProjectRequest {
        name: updated_project.name.clone(),
        description: updated_project.description.clone(),
    };
    let project_details = AppwriteService::update_project(data, id).await;

    match project_details {
        Ok(data) => HttpResponse::Accepted().json(APIResponse::<ProjectResponse> {
            status: StatusCode::OK.as_u16(),
            message: "success".to_string(),
            data: Some(data),
        }),
        Err(error) => HttpResponse::InternalServerError().json(APIErrorResponse {
            status: StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
            message: "failure".to_string(),
            data: Some(error.to_string()),
        }),
    }
}

#[delete("/project/{id}")]
pub async fn delete_project_handler(path: Path<String>) -> HttpResponse {
    let id = path.into_inner();
    if id.is_empty() {
        return HttpResponse::BadRequest().json(APIErrorResponse {
            status: StatusCode::BAD_REQUEST.as_u16(),
            message: "failure".to_string(),
            data: Some("invalid ID".to_string()),
        });
    };
    let project_details = AppwriteService::delete_project(id).await;

    match project_details {
        Ok(data) => HttpResponse::Accepted().json(APIResponse::<String> {
            status: StatusCode::ACCEPTED.as_u16(),
            message: "success".to_string(),
            data: Some(data),
        }),
        Err(error) => HttpResponse::InternalServerError().json(APIErrorResponse {
            status: StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
            message: "failure".to_string(),
            data: Some(error.to_string()),
        }),
    }
}
Enter fullscreen mode Exit fullscreen mode

Putting it all together

With that done, we must update the main.rs file to include our application entry point and use the handlers.

use actix_web::{App, HttpServer};
use api::handlers::{
    create_project_handler, delete_project_handler, get_project_handler, update_project_handler,
};
mod api;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(move || {
        App::new()
            .service(create_project_handler)
            .service(get_project_handler)
            .service(update_project_handler)
            .service(delete_project_handler)
    })
    .bind(("localhost", 8080))?
    .run()
    .await
}
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Imports the required dependencies
  • Creates a new server that adds the handlers and runs on localhost:8080

With that done, we can start a development server using the command below:

cargo run main
Enter fullscreen mode Exit fullscreen mode

create
update

Get details
Delete

We can also confirm the project management data by checking the collection on Appwrite.

Detail

Conclusion

This post discussed what Appwrite is and provided a detailed step-by-step guide to use it to build a project management API in Rust.

These resources may also be helpful:

Top comments (0)