A common paradigm in most programming languages is the use of files, modules, folders, and packages to break down codebases into separate and organized segments. Well, Rust isn’t an exception to this. You can structure your code in files and modules to achieve the desired result.
While files and modules work, they come with it’s limitations, as you can only use the separated code in a single codebase. An approach Rust uses to make your code reusable across multiple projects is the use of workspaces.
In this guide, you will learn what Rust workspaces are, their benefits, and a practical guide on how to build a REST API using Rust workspaces.
What are Rust Workspaces?
Rust workspace is a feature that lets you manage multiple related packages in one place. They let you structure your code for code reuse, simplify development workflow, and improve the overall developer experience.
Benefits of Rust Workspaces:
Code reusability: Workspaces allow you to share your code among multiple projects, which makes it easier to maintain and update.
Simplified dependency management: With workspaces, you can manage dependencies centrally for all member crates, avoid duplication, and ensure consistency.
Improved organization: Keep related projects together so that you can easily navigate and understand the codebase.
Streamlined builds: It lets you build all crates in the workspace simultaneously, which reduces build times and simplifies CI/CD processes.
In our case, you will use Rust Workspaces to build a REST API with Xata, a serverless data platform built on top of PostgreSQL for creating modern and robust applications.
The first step in creating workspaces is to architect your application by categorizing components into those that can be reused in multiple projects and those that are specific to a particular project. In this case, the data model and database service can be structured using workspaces, while the API handlers can leverage these workspaces.
Prerequisites
To follow along with this tutorial, you’ll need to have:
- Basic understanding of Rust
- Xata account. Signup is free
- Postman or any API testing application of your choice
Scaffold the project
To get started, navigate to the desired directory and run the command below:
cargo new rust-worskpace && cd rust-worskpace
This command creates a Rust project called rust-workspace
and navigates into the project directory.
Next, install the required dependency by modifying the [dependencies]
section of the Cargo.toml
file as shown below:
[dependencies]
actix-web = "4.4.1"
actix-web = "4.4.1"
is a Rust-based framework for building web applications.
Setup the database
Log into your Xata workspace and create a project
database with the following columns:
Column type | Column name |
---|---|
String | name |
Text | description |
String | status |
Next, click the Get code snippet button to get the database URL and navigate to your workspace settings to generate an API key.
Finally, create .env
file in the root directory and add the copied URL and API key.
XATA_DATABASE_URL= <REPLACE WITH THE COPIED DATABASE URL>
XATA_API_KEY=<REPLACE WITH THE COPIED API KEY>
With this done, you can now create the required workspaces that the application will use.
Data model workspace
To get started, run the command below:
cargo new --lib shared_models
The command above will create a library called shared_models
.
Next, install the required dependency by modifying the [dependencies]
section of the shared_models/Cargo.toml
file as shown below:
#shared_models/Cargo.toml
[dependencies]
serde = { version = "1.0.195", features = ["derive"] }
Finally, update the shared_models/src/lib.rs
file as shown below:
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Project {
pub id: String,
pub name: String,
pub description: String,
pub status: String,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ResponseData {
pub records: Vec<Project>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ProjectRequest {
pub name: String,
pub description: String,
pub status: String,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ProjectResponse {
pub id: String,
}
#[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
,ResponseData
,ProjectRequest
, andProjectResponse
structs with required properties to describe the request and response body accordingly. - Creates an
APIResponse
, andAPIErrorResponse
structs with the required properties needed for the API response
Database service workspace
To get started, run the command below:
cargo new --lib xata_client
The command above will create a library called xata_client
.
Next, install the required dependency by modifying the [dependencies]
section of the xata_client/Cargo.toml
file as shown below:
#xata_client/Cargo.toml
[dependencies]
dotenv = "0.15.0"
reqwest = { version = "0.11.23", features = ["json"] }
serde_json = "1.0.111"
shared_models = { path = "../shared_models" }
dotenv = "0.15.0"
is a library for loading environment variables.
reqwest = { version = "0.11", features = ["json"] }
for making HTTP requests.
serde_json = "1.0"
for manipulating JSON data.
shared_models = { path = "../shared_models" }
adds the model workspace you created earlier to xata_client
library. By doing this, you’ll be able to use the shared_models
workspace in this newly created workspace.
Finally, update the xata_client/src/lib.rs
file as shown below:
use std::env;
use dotenv::dotenv;
use reqwest::{header, Client, Error};
use shared_models::{Project, ProjectRequest, ProjectResponse, ResponseData};
pub struct XataClient;
impl XataClient {
fn env_loader(key: &str) -> String {
dotenv().ok();
match env::var(key) {
Ok(v) => v.to_string(),
Err(_) => format!("Error loading environment variable"),
}
}
fn init() -> Client {
Client::new()
}
fn create_header() -> header::HeaderMap {
let mut headers = header::HeaderMap::new();
headers.insert("Content-Type", "application/json".parse().unwrap());
headers.insert(
header::AUTHORIZATION,
format!("Bearer {}", XataClient::env_loader("XATA_API_KEY"))
.parse()
.unwrap(),
);
headers
}
pub async fn create_project(new_project: ProjectRequest) -> Result<ProjectResponse, Error> {
let database_url = XataClient::env_loader("XATA_DATABASE_URL");
let url = format!("{}:main/tables/Project/data", database_url);
let client = XataClient::init()
.post(url)
.headers(XataClient::create_header())
.json(&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),
}
}
pub async fn get_projects() -> Result<Vec<Project>, Error> {
let database_url = XataClient::env_loader("XATA_DATABASE_URL");
let url = format!("{}:main/tables/Project/query", database_url);
let client = XataClient::init()
.post(url)
.headers(XataClient::create_header())
.send()
.await;
match client {
Ok(response) => {
let json = response.text().await?;
let response_data: ResponseData = serde_json::from_str(json.as_str()).unwrap();
Ok(response_data.records)
}
Err(error) => Err(error),
}
}
}
The snippet above does the following:
- Imports the required dependencies
- Creates an
XataClient
struct - Creates an implementation block that adds
env_loader
,init
,create_header
,create_project
, andget_projects
methods. These methods are designed to load environment variables, create a connection pool for making asynchronous requests, generate request headers, create a project, and get the list of products.
Building the APIs with the workspaces
With the workspaces fully set up, you might think you can now start using the libraries in the main project. Well, not so fast—you need to specify them as dependencies in the main project first.
While Cargo automatically recognizes the libraries as part of the workspace, you still have to explicitly add them as dependencies. If you check the Cargo.toml
file in the root directory, you’ll see the workspaces added as shows below:
workspace = { members = ["shared_models", "xata_client"] }
To add the libraries as depencies, update the [dependencies]
section by specifying the library and the associated path.
#rust-workspace/Cargo.toml
[dependencies]
actix-web = "4.4.1"
#add workspaces below
shared_models = { path = "./shared_models" }
xata_client = { path = "./xata_client" }
With that done, you can now use the library to build the APIs.
To do this, create a handler.rs
file inside the src
folder of the project and add the snippet below:
use actix_web::{http::StatusCode, post, web::Json, HttpResponse};
use shared_models::{APIErrorResponse, APIResponse, Project, ProjectRequest, ProjectResponse};
use xata_client::XataClient;
#[post("/project")]
pub async fn create_project_handler(new_project: Json<ProjectRequest>) -> HttpResponse {
let create_project = XataClient::create_project(new_project.to_owned()).await;
match create_project {
Ok(data) => HttpResponse::Created().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()),
}),
}
}
#[post("/projects")]
pub async fn get_projects_handler() -> HttpResponse {
let get_projects = XataClient::get_projects().await;
match get_projects {
Ok(data) => HttpResponse::Ok().json(APIResponse::<Vec<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 (including the workspaces)
- Creates a
create_project_handler
andget_projects_handler
handler with corresponding API routes that use thelibraries
to perform the corresponding actions and return the appropriate response using theAPIResponse
andAPIErrorResponse
Putting it all together
With that done, update the main.rs
file to include the application entry point and use the handlers.
use actix_web::{App, HttpServer};
use handlers::{create_project_handler, get_projects_handler};
mod handlers;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(move || {
App::new()
.service(create_project_handler)
.service(get_projects_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
This post discussed what Rust workspaces are, their benefits, and a walkthrough guide on how to use them to build a REST API with Xata. Beyond what was explored above, you can extend the workspaces and the API to support deleting, editing, and getting a single project.
These resources may be helpful: