Appwrite + Rust: Build APIs without technical overhead

Learn how to abstract technical overhead by leveraging Appwrite to build a project management API in Rust

avatar

Demola Malomo

Aug 18 2023

8 min read

avatar

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:

1cargo new rust-appwrite && cd rust-appwrite

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:

1[dependencies] 2actix-web = "4" 3serde = { version = "1.0.145", features = ["derive"] } 4serde_json = "1.0" 5dotenv = "0.15.0" 6reqwest = { version = "0.11", features = ["json"] }

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:

1cargo build

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;

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

1mod api; 2 3fn main() { 4 println!("Hello world") 5}

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 keyAttribute typeSizeRequired
nameString250YES
descriptionString5000YES

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:

1API_KEY=<REPLACE WITH API KEY> 2PROJECT_ID=<REPLACE WITH PROJECT ID> 3DATABASE_ID=<REPLACE WITH DATABASE ID> 4COLLECTION_ID=<REPLACE WITH COLLECTION ID>

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:

1use serde::{Deserialize, Serialize}; 2 3#[derive(Deserialize, Serialize, Debug, Clone)] 4pub struct Project { 5 #[serde(rename = "$id")] 6 pub id: Option<String>, 7 pub name: String, 8 pub description: String, 9} 10 11#[derive(Deserialize, Serialize, Debug, Clone)] 12pub struct ProjectRequest { 13 pub name: String, 14 pub description: String, 15} 16 17#[derive(Deserialize, Serialize, Debug, Clone)] 18pub struct ProjectResponse { 19 #[serde(rename = "$id")] 20 pub id: String, 21 #[serde(rename = "$collectionId")] 22 pub collection_id: String, 23} 24 25#[derive(Deserialize, Serialize, Debug, Clone)] 26pub struct JsonAPIBody { 27 pub documentId: Option<String>, 28 pub data: ProjectRequest, 29} 30 31#[derive(Serialize, Debug, Clone)] 32pub struct APIResponse<T> { 33 pub status: u16, 34 pub message: String, 35 pub data: Option<T>, 36} 37 38#[derive(Serialize, Debug, Clone)] 39pub struct APIErrorResponse { 40 pub status: u16, 41 pub message: String, 42 pub data: Option<String>, 43}

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:

1use dotenv::dotenv; 2use reqwest::{header, Client, Error}; 3use std::env; 4use super::model::{JsonAPIBody, Project, ProjectRequest, ProjectResponse}; 5 6pub struct AppwriteService {} 7 8impl AppwriteService { 9 fn env_loader(key: &str) -> String { 10 dotenv().ok(); 11 match env::var(key) { 12 Ok(v) => v.to_string(), 13 Err(_) => format!("Error loading env variable"), 14 } 15 } 16 17 fn init() -> Client { 18 Client::new() 19 } 20 21 pub async fn create_project(new_project: ProjectRequest) -> Result<ProjectResponse, Error> { 22 //get details from environment variable 23 let project_id = AppwriteService::env_loader("PROJECT_ID"); 24 let database_id = AppwriteService::env_loader("DATABASE_ID"); 25 let collection_id = AppwriteService::env_loader("COLLECTION_ID"); 26 let api_key = AppwriteService::env_loader("API_KEY"); 27 28 let url = format!("https://cloud.appwrite.io/v1/databases/{database_id}/collections/{collection_id}/documents"); 29 30 //create header 31 let mut headers = header::HeaderMap::new(); 32 headers.insert("X-Appwrite-Key", api_key.parse().unwrap()); 33 headers.insert("X-Appwrite-Project", project_id.parse().unwrap()); 34 35 let client = AppwriteService::init() 36 .post(url) 37 .headers(headers) 38 .json(&JsonAPIBody { 39 documentId: Some("unique()".to_string()), 40 data: new_project, 41 }) 42 .send() 43 .await; 44 45 match client { 46 Ok(response) => { 47 let json = response.text().await?; 48 let created_project: ProjectResponse = serde_json::from_str(json.as_str()).unwrap(); 49 Ok(created_project) 50 } 51 Err(error) => Err(error), 52 } 53 } 54}

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.

1//imports goes here 2 3pub struct AppwriteService {} 4 5impl AppwriteService { 6 //helper method goes here 7 8 pub async fn create_project(new_project: ProjectRequest) -> Result<ProjectResponse, Error> { 9 //create_project code goes here 10 } 11 12 pub async fn get_project(document_id: String) -> Result<Project, Error> { 13 //get details from environment variable 14 let project_id = AppwriteService::env_loader("PROJECT_ID"); 15 let database_id = AppwriteService::env_loader("DATABASE_ID"); 16 let collection_id = AppwriteService::env_loader("COLLECTION_ID"); 17 let api_key = AppwriteService::env_loader("API_KEY"); 18 19 let url = format!("https://cloud.appwrite.io/v1/databases/{database_id}/collections/{collection_id}/documents/{document_id}"); 20 21 //create header 22 let mut headers = header::HeaderMap::new(); 23 headers.insert("X-Appwrite-Key", api_key.parse().unwrap()); 24 headers.insert("X-Appwrite-Project", project_id.parse().unwrap()); 25 26 let client = AppwriteService::init() 27 .get(url) 28 .headers(headers) 29 .send() 30 .await; 31 32 match client { 33 Ok(response) => { 34 let json = response.text().await?; 35 let project_detail: Project = serde_json::from_str(json.as_str()).unwrap(); 36 Ok(project_detail) 37 } 38 Err(error) => Err(error), 39 } 40 } 41}

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.

1//imports go here 2 3pub struct AppwriteService {} 4 5impl AppwriteService { 6 //helper method goes here 7 8 pub async fn create_project(new_project: ProjectRequest) -> Result<ProjectResponse, Error> { 9 //create_project code goes here 10 } 11 12 pub async fn get_project(document_id: String) -> Result<Project, Error> { 13 //get_project goes here 14 } 15 16 pub async fn update_project( 17 updated_project: ProjectRequest, 18 document_id: String, 19 ) -> Result<ProjectResponse, Error> { 20 //get details from environment variable 21 let project_id = AppwriteService::env_loader("PROJECT_ID"); 22 let database_id = AppwriteService::env_loader("DATABASE_ID"); 23 let collection_id = AppwriteService::env_loader("COLLECTION_ID"); 24 let api_key = AppwriteService::env_loader("API_KEY"); 25 26 let url = format!("https://cloud.appwrite.io/v1/databases/{database_id}/collections/{collection_id}/documents/{document_id}"); 27 28 //create header 29 let mut headers = header::HeaderMap::new(); 30 headers.insert("X-Appwrite-Key", api_key.parse().unwrap()); 31 headers.insert("X-Appwrite-Project", project_id.parse().unwrap()); 32 33 let client = AppwriteService::init() 34 .patch(url) 35 .headers(headers) 36 .json(&JsonAPIBody { 37 documentId: None, 38 data: updated_project, 39 }) 40 .send() 41 .await; 42 43 match client { 44 Ok(response) => { 45 let json = response.text().await?; 46 let updates: ProjectResponse = serde_json::from_str(json.as_str()).unwrap(); 47 Ok(updates) 48 } 49 Err(error) => Err(error), 50 } 51 } 52}

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.

1//import goes here 2 3pub struct AppwriteService {} 4 5impl AppwriteService { 6 //helper method goes here 7 8 pub async fn create_project(new_project: ProjectRequest) -> Result<ProjectResponse, Error> { 9 //create_project code goes here 10 } 11 12 pub async fn get_project(document_id: String) -> Result<Project, Error> { 13 //get_project goes here 14 } 15 16 pub async fn update_project( 17 updated_project: ProjectRequest, 18 document_id: String, 19 ) -> Result<ProjectResponse, Error> { 20 //update_project code goes here 21 } 22 23 pub async fn delete_project(document_id: String) -> Result<String, Error> { 24 //get details from environment variable 25 let project_id = AppwriteService::env_loader("PROJECT_ID"); 26 let database_id = AppwriteService::env_loader("DATABASE_ID"); 27 let collection_id = AppwriteService::env_loader("COLLECTION_ID"); 28 let api_key = AppwriteService::env_loader("API_KEY"); 29 30 let url = format!("https://cloud.appwrite.io/v1/databases/{database_id}/collections/{collection_id}/documents/{document_id}"); 31 32 //create header 33 let mut headers = header::HeaderMap::new(); 34 headers.insert("X-Appwrite-Key", api_key.parse().unwrap()); 35 headers.insert("X-Appwrite-Project", project_id.parse().unwrap()); 36 37 let client = AppwriteService::init() 38 .delete(url) 39 .headers(headers) 40 .send() 41 .await; 42 43 match client { 44 Ok(_) => { 45 let json = format!("Project with ID: ${document_id} deleted successfully!!"); 46 Ok(json) 47 } 48 Err(error) => Err(error), 49 } 50 } 51}

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:

1use super::{ 2 model::{APIErrorResponse, APIResponse, Project, ProjectRequest, ProjectResponse}, 3 services::AppwriteService, 4}; 5use actix_web::{ 6 delete, get, patch, post, 7 web::{Json, Path}, 8 HttpResponse, 9}; 10use reqwest::StatusCode; 11 12#[post("/project")] 13pub async fn create_project_handler(data: Json<ProjectRequest>) -> HttpResponse { 14 let new_project = ProjectRequest { 15 name: data.name.clone(), 16 description: data.description.clone(), 17 }; 18 let project_details = AppwriteService::create_project(new_project).await; 19 20 match project_details { 21 Ok(data) => HttpResponse::Accepted().json(APIResponse::<ProjectResponse> { 22 status: StatusCode::CREATED.as_u16(), 23 message: "success".to_string(), 24 data: Some(data), 25 }), 26 Err(error) => HttpResponse::InternalServerError().json(APIErrorResponse { 27 status: StatusCode::INTERNAL_SERVER_ERROR.as_u16(), 28 message: "failure".to_string(), 29 data: Some(error.to_string()), 30 }), 31 } 32} 33 34#[get("/project/{id}")] 35pub async fn get_project_handler(path: Path<String>) -> HttpResponse { 36 let id = path.into_inner(); 37 if id.is_empty() { 38 return HttpResponse::BadRequest().json(APIErrorResponse { 39 status: StatusCode::BAD_REQUEST.as_u16(), 40 message: "failure".to_string(), 41 data: Some("invalid ID".to_string()), 42 }); 43 }; 44 let project_details = AppwriteService::get_project(id).await; 45 46 match project_details { 47 Ok(data) => HttpResponse::Accepted().json(APIResponse::<Project> { 48 status: StatusCode::OK.as_u16(), 49 message: "success".to_string(), 50 data: Some(data), 51 }), 52 Err(error) => HttpResponse::InternalServerError().json(APIErrorResponse { 53 status: StatusCode::INTERNAL_SERVER_ERROR.as_u16(), 54 message: "failure".to_string(), 55 data: Some(error.to_string()), 56 }), 57 } 58}

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.

1//import goes here 2 3#[post("/project")] 4pub async fn create_project_handler(data: Json<ProjectRequest>) -> HttpResponse { 5 //create_project_handler code goes here 6} 7 8#[get("/project/{id}")] 9pub async fn get_project_handler(path: Path<String>) -> HttpResponse { 10 //get_project_handler code goes here 11} 12 13#[patch("/project/{id}")] 14pub async fn update_project_handler( 15 updated_project: Json<ProjectRequest>, 16 path: Path<String>, 17) -> HttpResponse { 18 let id = path.into_inner(); 19 if id.is_empty() { 20 return HttpResponse::BadRequest().json(APIErrorResponse { 21 status: StatusCode::BAD_REQUEST.as_u16(), 22 message: "failure".to_string(), 23 data: Some("invalid ID".to_string()), 24 }); 25 }; 26 let data = ProjectRequest { 27 name: updated_project.name.clone(), 28 description: updated_project.description.clone(), 29 }; 30 let project_details = AppwriteService::update_project(data, id).await; 31 32 match project_details { 33 Ok(data) => HttpResponse::Accepted().json(APIResponse::<ProjectResponse> { 34 status: StatusCode::OK.as_u16(), 35 message: "success".to_string(), 36 data: Some(data), 37 }), 38 Err(error) => HttpResponse::InternalServerError().json(APIErrorResponse { 39 status: StatusCode::INTERNAL_SERVER_ERROR.as_u16(), 40 message: "failure".to_string(), 41 data: Some(error.to_string()), 42 }), 43 } 44} 45 46#[delete("/project/{id}")] 47pub async fn delete_project_handler(path: Path<String>) -> HttpResponse { 48 let id = path.into_inner(); 49 if id.is_empty() { 50 return HttpResponse::BadRequest().json(APIErrorResponse { 51 status: StatusCode::BAD_REQUEST.as_u16(), 52 message: "failure".to_string(), 53 data: Some("invalid ID".to_string()), 54 }); 55 }; 56 let project_details = AppwriteService::delete_project(id).await; 57 58 match project_details { 59 Ok(data) => HttpResponse::Accepted().json(APIResponse::<String> { 60 status: StatusCode::ACCEPTED.as_u16(), 61 message: "success".to_string(), 62 data: Some(data), 63 }), 64 Err(error) => HttpResponse::InternalServerError().json(APIErrorResponse { 65 status: StatusCode::INTERNAL_SERVER_ERROR.as_u16(), 66 message: "failure".to_string(), 67 data: Some(error.to_string()), 68 }), 69 } 70}

Putting it all together

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

1use actix_web::{App, HttpServer}; 2use api::handlers::{ 3 create_project_handler, delete_project_handler, get_project_handler, update_project_handler, 4}; 5mod api; 6 7#[actix_web::main] 8async fn main() -> std::io::Result<()> { 9 HttpServer::new(move || { 10 App::new() 11 .service(create_project_handler) 12 .service(get_project_handler) 13 .service(update_project_handler) 14 .service(delete_project_handler) 15 }) 16 .bind(("localhost", 8080))? 17 .run() 18 .await 19}

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:

1cargo run main

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:

Related posts