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
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"] }
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
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.
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:
mod api;
fn main() {
println!("Hello world")
}
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 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.
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.
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 |
After creating the attributes, we see them as shown below:
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.
Next, input api_rust
as the name, click the Next button, select Database as the required scope, and Create.
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>
We can get the required API key and IDs from our Appwrite console as shown below:
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>,
}
The snippet above does the following:
- Imports the required dependency
- Creates a
Project
,ProjectRequest
,ProjectResponse
, andJsonAPIBody
structs with required properties to describe request and response body accordingly - Creates an
APIResponse
, andAPIErrorResponse
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),
}
}
}
The snippet above does the following:
- Imports the required dependencies
- Creates an
AppwriteService
struct - Creates an implementation block that adds
env_loader
andinit
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),
}
}
}
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),
}
}
}
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),
}
}
}
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()),
}),
}
}
The snippet above does the following:
- Imports the required dependencies
- Creates a
create_project_handler
andget_project_handler
handler with corresponding API routes that use the services to perform the corresponding actions and return the appropriate response using theAPIResponse
andAPIErrorResponse
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()),
}),
}
}
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
}
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
We can also confirm the project management data by checking the collection on Appwrite.
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: