Rust Workspaces: A guide to managing your code better

Demola Malomo - Aug 6 - - Dev Community

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:

  1. Code reusability: Workspaces allow you to share your code among multiple projects, which makes it easier to maintain and update.

  2. Simplified dependency management: With workspaces, you can manage dependencies centrally for all member crates, avoid duplication, and ensure consistency.

  3. Improved organization: Keep related projects together so that you can easily navigate and understand the codebase.

  4. 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.

Workspace showcase

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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

database

Next, click the Get code snippet button to get the database URL and navigate to your workspace settings to generate an API key.

Get URL

Generate 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"] }
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Imports the required dependency.
  • Creates a Project, ResponseData, ProjectRequest, and ProjectResponse structs with required properties to describe the request and response body accordingly.
  • Creates an APIResponse, and APIErrorResponse 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
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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, and get_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"] }
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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()),
        }),
    }
}
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Imports the required dependencies (including the workspaces)
  • Creates a create_project_handler and get_projects_handler handler with corresponding API routes that use the libraries to perform the corresponding actions and return the appropriate response using the APIResponse and APIErrorResponse

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
}
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
Enter fullscreen mode Exit fullscreen mode

API test

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:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .