Creating a Simple API with Rust and PostgreSQL

laurentiu.raducu

Creating a Simple API with Rust and PostgreSQL

Welcome to another exciting installment on Rust! Today, we’re going to delve into the world of API development with a fantastic blend of Rust and Postgres. If you’re a fan of blazing-fast performance, strong type safety, and efficient memory management, then you’re in for a treat. Rust has been gaining popularity among developers as a language of choice for building robust and reliable systems, while Postgres is renowned for its stability, extensibility, and SQL compliance. Moreover, it won the title of the most-loved programming language based on the 2022 developer survey published by Stack Overflow. Together, they form a powerful duo that we’ll harness to create a simple, yet elegant HTTP API for managing book entries in a database.

Our API will be designed to handle the creation of book objects, each consisting of three key properties: title, author, and year. By the end of this tutorial, you’ll have a solid grasp on how to build and deploy a fully-functional API using Rust and Postgres. So, whether you’re a seasoned developer looking to expand your skillset or a coding enthusiast eager to explore new horizons, this guide promises to be an engaging and informative journey. Grab your keyboard and let’s get started!

Prerequisites

  1. Ensure that you have Rust and Cargo installed on your system. If you haven’t, visit https://www.rust-lang.org/tools/install to install Rust and Cargo.
  2. Install PostgreSQL on your system, if you haven’t already. Follow the instructions at https://www.postgresql.org/download/ to download and install PostgreSQL for your platform.
  3. Create a new PostgreSQL database and user for the API to use. Take note of the database name, username, and password. You’ll need them to set up the DATABASE_URL environment variable.
  4. In your project directory, create a .env file to store your environment variables. Set the DATABASE_URL variable with the connection string to your PostgreSQL database. The connection string should be in the format postgres://username:password@localhost/dbname. Replace username, password, and dbname with the appropriate values for your PostgreSQL database.Example .env file:
DATABASE_URL=postgres://username:password@localhost/dbname

The Cargo.toml File: Configuring Your Rust Project

Before diving into the code, let’s take a moment to discuss the Cargo.toml file, which serves as the configuration file for our Rust project. This file is essential, as it specifies the metadata, dependencies, and build settings for our API.

The [package] section contains information about our project, such as its name, version, and Rust edition. In this case, we’re building an API named rust-api using the 2022 edition of Rust:

[package]
name = "rust-api"
version = "0.1.0"
edition = "2021"

The [dependencies] section lists all the external libraries (crates) our project depends on, along with their respective versions. In our API, we’re using the following crates:

  • rocket and rocket_codegen: These crates provide the core framework and code generation capabilities for building web applications with Rust.
  • diesel: A powerful ORM (Object Relational Mapper) and query builder for Rust. We’re using the postgres feature to enable support for Postgres databases.
  • dotenv: A crate that allows us to load environment variables from a .env file, which is useful for managing database connection settings.
  • r2d2-diesel and r2d2: These crates provide a connection pooling system for Diesel, allowing us to efficiently manage database connections.
  • serde, serde_derive, and serde_json: Serde is a powerful serialization framework for Rust. These crates enable us to easily convert between Rust structs and JSON data.
  • custom_derive: A crate that provides utilities for implementing custom derive attributes in Rust.
[dependencies]
rocket = "0.4.4"
rocket_codegen = "0.4.4"
diesel = { version = "1.4.4", features = ["postgres"] }
dotenv = "0.9.0"
r2d2-diesel = "1.0"
r2d2 = "0.8"
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
custom_derive ="0.1.7"

Finally, we have a special [dependencies.rocket_contrib] section. This is a sub-configuration for the rocket_contrib crate, which contains various useful components for working with Rocket. We’re using the wildcard * to specify the latest compatible version, disabling its default features, and only enabling the json feature, which allows us to handle JSON data in our API.

[dependencies.rocket_contrib]
version = "*"
default-features = false
features = ["json"]

Database Connection Management: The db.rs File

The db.rs file is responsible for managing our database connections using the Diesel and r2d2 crates. This file includes essential types, functions, and implementations required to establish and maintain connections with our Postgres database.

use diesel::pg::PgConnection;
use r2d2;
use r2d2_diesel::ConnectionManager;
use rocket::http::Status;
use rocket::request::{self, FromRequest};
use rocket::{Outcome, Request, State};
use std::ops::Deref;

First, we import the necessary modules and types from Diesel, r2d2, and Rocket, as well as the Deref trait from the Rust standard library.

pub type Pool = r2d2::Pool<ConnectionManager<PgConnection>>;

We define a type alias Pool as an r2d2 connection pool that manages PgConnection instances through a ConnectionManager.

pub fn init_pool(db_url: String) -> Pool {
    let manager = ConnectionManager::<PgConnection>::new(db_url);
    r2d2::Pool::new(manager).expect("db pool failure")
}

We provide a public function init_pool that takes a database URL as input and returns an initialized Pool. This function creates a new ConnectionManager instance with the provided URL and initializes the r2d2 connection pool.

pub struct Conn(pub r2d2::PooledConnection<ConnectionManager<PgConnection>>);

We define a public wrapper struct Conn for the PooledConnection type, which represents a single connection managed by the r2d2 pool.

impl<'a, 'r> FromRequest<'a, 'r> for Conn {
    type Error = ();

    fn from_request(request: &'a Request<'r>) -> request::Outcome<Conn, ()> {
        let pool = request.guard::<State<Pool>>()?;
        match pool.get() {
            Ok(conn) => Outcome::Success(Conn(conn)),
            Err(_) => Outcome::Failure((Status::ServiceUnavailable, ())),
        }
    }
}

We implement the FromRequest trait for Conn, which allows us to easily use Conn instances as request guards in our Rocket routes. In the from_request method, we attempt to obtain a connection from the pool. If successful, we return an Outcome::Success variant containing a Conn instance; otherwise, we return an Outcome::Failure with a ServiceUnavailable status.

impl Deref for Conn {
    type Target = PgConnection;

    #[inline(always)]
    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

Lastly, we implement the Deref trait for Conn to allow for automatic dereferencing. This implementation simplifies accessing the underlying PgConnection when using a Conn instance, making it more convenient to work with.

With the db.rs file set up, our Rust API is now equipped to efficiently manage database connections with Postgres. In the upcoming sections, we will explore how to create models and routes to interact with our database and expose its functionality through the API.

Working with Books: The models.rs File

Now it’s time to take a look at the provided models.rs file, which defines the structure, data, and methods for interacting with the Book objects in our Rust API. This file leverages Diesel’s powerful features to handle database operations with our Postgres database.

use diesel;
use diesel::pg::PgConnection;
use diesel::prelude::*;
use super::schema::books;
use super::schema::books::dsl::books as all_books;

First, we import the necessary modules and types from Diesel and the books table schema, which will be generated by Diesel based on our database schema.

#[derive(Serialize, Queryable)]
pub struct Book {
    pub id: i32,
    pub title: String,
    pub author: String,
    pub year: String,
}

We define a public Book struct and derive the Serialize and Queryable traits. This struct represents a book object in our API, with fields for id, title, author, and year.

#[derive(Deserialize)]
pub struct BookData {
    pub author: String,
}

We define a BookData struct and derive the Deserialize trait. This struct represents the data we expect to receive from the client when making a request to our API. In this case, we only expect the author field.

#[derive(Serialize, Deserialize, Insertable)]
#[table_name = "books"]
pub struct NewBook {
    pub title: String,
    pub author: String,
    pub year: String,
}

We define a NewBook struct and derive the Serialize, Deserialize, and Insertable traits. This struct is used for creating new books in our database. We also specify the #[table_name = "books"] attribute to associate the struct with the books table.

impl Book {
    pub fn get_all_books(conn: &PgConnection) -> Vec<Book> {
        all_books
            .order(books::id.desc())
            .load::<Book>(conn)
            .expect("error!")
    }

    pub fn insert_book(book: NewBook, conn: &PgConnection) -> Book {
        diesel::insert_into(books::table)
            .values(&book)
            .get_result(conn)
            .expect("Error saving book")
    }
}

Finally, we implement two public methods for the Book struct:

  • get_all_books: This method takes a reference to a PgConnection and returns a Vec<Book> containing all the books in the database. We use the all_books query DSL (Domain Specific Language) variable to define the query, ordering the results by descending id. The load::<Book>(conn) function fetches the data and maps it to Book instances.
  • insert_book: This method takes a NewBook instance and a reference to a PgConnection, inserting the new book into the database and returning the created Book object. We use Diesel’s insert_into function to generate an SQL INSERT statement, followed by the values function to supply the data and the get_result function to execute the query and return the result.

With the book.rs file in place, our Rust API is now equipped to manage book objects in our Postgres database. In the next sections, we’ll discuss how to create routes for interacting with the books and expose the API’s functionality to clients.

Defining the Schema: The books Table

Just before we discuss about routes, let’s also touch upon the provided schema definition for the books table, which is essential for Diesel to correctly map our Rust structs to the corresponding database columns. The schema definition is typically placed in a schema.rs file.

table! {
    books (id) {
        id -> Int4,
        author -> Varchar,
        title -> Varchar,
        year -> Varchar,
    }
}

The table! macro is used to define the schema for the books table. It specifies the primary key column id and lists the other columns in the table:

  • id: The primary key column of type Int4 (4-byte integer), which uniquely identifies each book entry in the table.
  • author: A column of type Varchar (variable-length character string), representing the book’s author.
  • title: A column of type Varchar, representing the book’s title.
  • year: A column of type Varchar, representing the book’s publication year.

By defining the table schema, we enable Diesel to generate the appropriate code for interacting with the books table in our database. This schema information is used to create the necessary SQL queries, allowing us to perform operations like inserting new books, fetching book data, and updating or deleting existing books.

Setting Up API Routes: The routes.rs File

In this section, we’ll discuss the routes.rs file, which defines the routes for our Rust API. These routes enable clients to interact with our API to perform operations on the books table in our Postgres database.

use super::db::Conn as DbConn;
use rocket_contrib::json::Json;
use super::models::{Book, NewBook};
use serde_json::Value;
use crate::models::BookData;

First, we import the necessary modules and types from our db and models modules, as well as the Json type from rocket_contrib and the Value type from serde_json.

#[get("/books", format = "application/json")]
pub fn get_all(conn: DbConn) -> Json<Value> {
    let books = Book::get_all_books(&conn);
    Json(json!({
        "status": 200,
        "result": books,
    }))
}

We define a public get_all function and annotate it with the #[get] attribute, specifying the /books endpoint and a format of application/json. This function takes a DbConn instance as an argument and returns a Json<Value> response. Inside the function, we call the Book::get_all_books method to fetch all books from the database and construct a JSON response containing the status code and the fetched books.

#[post("/books", format = "application/json", data = "<new_book>")]
pub fn new_book(conn: DbConn, new_book: Json<NewBook>) -> Json<Value> {
    Json(json!({
        "status": Book::insert_book(new_book.into_inner(), &conn),
        "result": Book::get_all_books(&conn).first(),
    }))
}

We define a public new_book function and annotate it with the #[post] attribute, specifying the /books endpoint, a format of application/json, and a data parameter named new_book. This function takes a DbConn instance and a Json<NewBook> instance as arguments and returns a Json<Value> response. Inside the function, we call the Book::insert_book method to insert the new book into the database and then fetch the latest book entry to include in the JSON response.

Bringing It All Together: The main.rs File

In this closing section, we’ll examine the main.rs file, which ties together all the components of our Rust API with Postgres. This file sets up the necessary dependencies, imports the required modules, initializes the database connection pool, configures the routes, and launches the API.

#![feature(plugin, decl_macro, proc_macro_hygiene)]
#![allow(proc_macro_derive_resolution_fallback, unused_attributes)]

#[macro_use]
extern crate diesel;
extern crate dotenv;
extern crate r2d2;
extern crate r2d2_diesel;
#[macro_use]
extern crate rocket;
extern crate rocket_contrib;
#[macro_use]
extern crate serde_derive;
#[macro_use]
extern crate serde_json;

We start by enabling necessary Rust features and importing the required crates, such as diesel, dotenv, r2d2, rocket, and serde.

use dotenv::dotenv;
use std::env;
use routes::*;
use std::process::Command;

mod db;
mod models;
mod routes;
mod schema;

Next, we import the necessary modules and types from our previously discussed db, models, routes, and schema modules.

fn rocket() -> rocket::Rocket {
    dotenv().ok();

    let database_url = env::var("DATABASE_URL").expect("set DATABASE_URL");

    let pool = db::init_pool(database_url);
    rocket::ignite()
        .manage(pool)
        .mount(
            "/api/v1/",
            routes![get_all, new_book],
        )
}

We define a rocket() function that initializes the .env file using dotenv(), reads the DATABASE_URL environment variable, and initializes the connection pool by calling db::init_pool. The function then creates a new Rocket instance using rocket::ignite(), registers the connection pool with Rocket’s state management, and mounts the routes at the /api/v1/ base path using the routes![get_all, new_book] macro.

fn main() {
    rocket().launch();
}

Finally, in the main() function, we call the rocket() function to create a new Rocket instance and then launch the API with the .launch() method.

With the main.rs file in place, our Rust API is now complete and ready for use. This powerful and efficient API allows clients to interact with a Postgres database to manage book entries, offering routes for fetching all books and creating new ones. By leveraging Rust’s type safety, performance, and the power of the Diesel ORM, you have successfully built a robust and easy-to-maintain API.

To launch his API, use the following command:

cargo run

This will download the dependencies and will start the API.

I hope you found this tutorial helpful and I wish to see you in the next one!