Creating a CRUD REST API With Actix Web (Part 1)

1. Overview

In this tutorial, we’ll learn through a step-by-step guide on building a CRUD (Create, Read, Update, Delete) REST API using Actix Web and PostgreSQL. We’ll build an API for a simple book management application.

2. Project Setup

To begin, let’s create a new Rust project “cargo new bookapi“. Next, let’s add the following dependencies to the cargo.toml file:

[dependencies]
actix-web = { version = "4.4.0", features = ["openssl"] }
actix-files = "0.6.2"
serde_json = "1.0.53"
diesel = { version = "2.1.3", features = ["postgres", "r2d2"] }
serde = { version = "1.0.110", features = ["derive"] }
// ...
  • actix-web – the web framework
  • diesel – ORM for interacting with PostgreSQL
  • serde – for JSON serialization and deserialization

3. Model Setup

First, let’s create a new rust module named “models.rs“. Then, let’s add a struct that contains the id, name, and author of a book:

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

Also, let’s create a new struct named “NewBook” to add a new book to the database:

#[derive(Debug, Insertable, AsChangeset, Serialize, Deserialize, Clone)]
#[table_name = "books"]
pub struct NewBook {
    pub name: String,
    pub author: String,
}

Finally, let’s implement From for the NewBook struct:

impl From<web::Json<NewBook>> for NewBook {
    fn from(book: web::Json<NewBook>) -> Self {
        NewBook {
            name: book.name.clone(),
            author: book.author.clone(),
        }
    }
}

In the code above, we define an implementation for the From trait for the NewBook struct. It lets us map from Json to the Book struct while creating a new book.

4. Database Setup

Let’s create a new module named “db.rs” and add a function named setup_database to connect to PostgreSQL DB:

pub type DbPool = r2d2::Pool<ConnectionManager<PgConnection>>;
pub fn setup_database() -> DbPool {
    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let manager = ConnectionManager::<PgConnection>::new(&database_url);
    r2d2::Pool::builder()
      .build(manager)
      .expect("Failed to create DB connection pool.")
}

Here, we create a function that establishes a database connection via an environment variable. The DbPool can be injected into our endpoints for database access.

Finally, let’s create a new module named “schema.rs” to define database schema connection:

diesel::table! {
    books (id) {
        id -> Int4,
        name -> Varchar,
        author -> Varchar,
    }
}

In the code above, we invoke the table! macro from diesel to define a new database table schema.

5. Handling Error

Error handling is an important aspect of API development. Let’s define a new module named “error.rs” to define a custom error for the book API.

First, let’s create an enum to define three error types:

#[derive(Display, Debug)]
pub enum UserError {
    #[display(fmt = "Invalid input parameter")]
    ValidationError,
    #[display(fmt = "Internal server error")]
    InternalError,
    #[display(fmt = "Not found")]
    NotFoundError,
}

Next, let’s write a function to return the status code and error response:

impl error::ResponseError for UserError {
    fn error_response(&self) -> HttpResponse {
        HttpResponse::build(self.status_code()).json(json!({ "msg": self.to_string() }))
    }
    fn status_code(&self) -> StatusCode {
        match *self {
            UserError::ValidationError => StatusCode::BAD_REQUEST,
            UserError::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
            UserError::NotFoundError => StatusCode::NOT_FOUND,
        }
    }
}

Here, we define a function to map user error to a complete HTTP response. Proper error handling in REST API gives a clear picture of what could be wrong.

6. CRUD Functions

6.1. Get Request

Let’s create the function to get the list of all books from the database:

async fn get_books(pool: web::Data<DbPool>) -> Result<HttpResponse, UserError> {
    let mut connection = pool.get().map_err(|_| {
        error!("Failed to get DB connection from pool");
        UserError::InternalError
    })?;
    let books_data = web::block(move || books.limit(100).load::<Book>(&mut connection))
      .await
      .map_err(|_| UserError::InternalError)?
      .map_err(|e| match e {
          diesel::result::Error::NotFound => UserError::NotFoundError,
          _ => UserError::InternalError,
    })?;
    Ok(HttpResponse::Ok().json(books_data))
}

Here, we create an async function to return the list of all books in the database. Also, we limit the result to 100. The function accepts DbPool as an argument and we return a Result.

6.2. Post Request

Additionally, let’s write a function to add book details to the database:

async fn create_book(pool: web::Data<DbPool>,new_book: web::Json<NewBook>,) -> Result<HttpResponse, UserError> {
    let mut connection = pool.get().map_err(|_| {
        error!("Failed to get DB connection from pool");
        UserError::InternalError
    })?;
    let book_data = web::block(move || {
        diesel::insert_into(books)
          .values(&*new_book)
          .get_result::<Book>(&mut connection)
    })
    .await
    .map_err(|_| UserError::InternalError)?
    .map_err(|e| match e {
        diesel::result::Error::NotFound => {
            error!("Book ID: not found in DB");
            UserError::NotFoundError
        }
        _ => UserError::InternalError,
    })?;
    Ok(HttpResponse::Ok().json(book_data))
}

Here, we create a function to create a new book. The deserialized JSON is mapped to the NewBook struct to represent the book data in the database. Also, we handle the possible exceptions and return the book data as a Result.

6.3. Get a Book by Id

Let’s get a book by its ID. First, let’s create a path variable:

#[derive(Deserialize, Validate)]
struct BookEndpointPath {
    id: i32,
}

Next, let’s write a function to fetch a book by its id:

async fn get_book(pool: web::Data, book_id: web::Path,) -> Result<HttpResponse, UserError> {
    book_id.validate().map_err(|_| {
        warn!("Parameter validation failed");
        UserError::ValidationError
    })?;
    let mut connection = pool.get().map_err(|_| {
        error!("Failed to get DB connection from pool");
        UserError::InternalError
    })?;

    let query_id = book_id.id;
    let book_data =
        web::block(move || books.filter(id.eq(query_id)).first::(&mut connection))
            .await
            .map_err(|_| UserError::InternalError)?
            .map_err(|e| match e {
                diesel::result::Error::NotFound => {
                    error!("Book ID: {} not found in DB", &book_id.id);
                    UserError::NotFoundError
                }
                _ => UserError::InternalError,
            })?;

    Ok(HttpResponse::Ok().json(book_data))
}

As usual, we establish a connection to the database and filter book by its id. Finally, we return the result as JSON.

6.4. Edit a Book

Let’s write a function to perform a PUT request:

async fn update_book(pool: web::Data, updated_book: web::Json, book_id: web::Path,) -> Result<HttpResponse, UserError> {
    let mut connection = pool.get().map_err(|_| {
        error!("Failed to get DB connection from pool");
        UserError::InternalError
    })?;

    let query_id = book_id.id;

    let book_data = web::block(move || {
        diesel::update(books.find(query_id))
            .set(&*updated_book)
            .get_result::(&mut connection)
    })
    .await
    .map_err(|_| UserError::InternalError)?
    .map_err(|e| match e {
        diesel::result::Error::NotFound => {
            error!("Book ID: not found in DB");
            UserError::NotFoundError
        }
        _ => UserError::InternalError,
    })?;

    Ok(HttpResponse::Ok().json(book_data))
}

Mutating store data is one of the attributes of the CRUD application. First, we got a book by its id. Then, we accept data to modify the content of the book with its unique ID.

6.5. Delete a Book

Finally, let’s see a function to delete a book from the repository:

async fn delete_book(pool: web::Data, book_id: web::Path,) -> Result<HttpResponse, UserError> {
    let mut connection = pool.get().map_err(|_| {
        error!("Failed to get DB connection from pool");
        UserError::InternalError
    })?;

    let query_id = book_id.id;

    let book_data =
        web::block(move || diesel::delete(books.filter(id.eq(query_id))).execute(&mut connection))
            .await
            .map_err(|_| UserError::InternalError)?
            .map_err(|e| match e {
                diesel::result::Error::NotFound => {
                    error!("Book ID: not found in DB");
                    UserError::NotFoundError
                }
                _ => UserError::InternalError,
            })?;

    Ok(HttpResponse::Ok().json(book_data))
}

Finally, we create a function to delete a book by its id. We invoke the delete() method from diesel ORM to initialize the delete process.

7. Conclusion

In this article, we built a simple CRUD REST API with Rust using:

  • Actix-web for the web server and routing
  • Diesel as the ORM for PostgreSQL integration
  • JSON serialization using Serde

The API provides basic CRUD operations for managing books. Also, we handle possible errors. In the next part of this article, we’ll see how to test the API with Postman.

The full source code for the examples is available over on GitHub.