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
- 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.
- 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.
- 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. - In your project directory, create a
.env
file to store your environment variables. Set theDATABASE_URL
variable with the connection string to your PostgreSQL database. The connection string should be in the formatpostgres://username:password@localhost/dbname
. Replaceusername
,password
, anddbname
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
androcket_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 thepostgres
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
andr2d2
: These crates provide a connection pooling system for Diesel, allowing us to efficiently manage database connections.serde
,serde_derive
, andserde_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 aPgConnection
and returns aVec<Book>
containing all the books in the database. We use theall_books
query DSL (Domain Specific Language) variable to define the query, ordering the results by descendingid
. Theload::<Book>(conn)
function fetches the data and maps it toBook
instances.insert_book
: This method takes aNewBook
instance and a reference to aPgConnection
, inserting the new book into the database and returning the createdBook
object. We use Diesel’sinsert_into
function to generate an SQLINSERT
statement, followed by thevalues
function to supply the data and theget_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 typeInt4
(4-byte integer), which uniquely identifies each book entry in the table.author
: A column of typeVarchar
(variable-length character string), representing the book’s author.title
: A column of typeVarchar
, representing the book’s title.year
: A column of typeVarchar
, 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!