Book a Bookstore API using Rack, Sequel and Postgresql
Tags: ruby, rails, beginners, tutorial
Published: 2023-04-26 16:01:20

In this article, we will build a simple Bookstore API using Rack and PostgreSQL. Rack is a lightweight web framework for Ruby that provides a minimal interface between web servers and Ruby applications. We will use PostgreSQL as our database backend.

We will create an API with two endpoints: a GET endpoint for retrieving all books and a POST endpoint for adding new books. Additionally, we will provide scripts to create and delete the bookstore database.

Prerequisites:
- **Ruby**
- **PostgreSQL**

First, create a new directory for your project:

```bash
mkdir bookstore_api
cd bookstore_api
```

Create a `Gemfile` to manage your project's dependencies:

```ruby
# Gemfile
source 'https://rubygems.org'

gem 'rack'
gem 'pg'
gem 'sequel'
gem 'rack-cors'
gem 'json'
```

Run `bundle install` to install the required gems.


## Create the database

Create a new file called `db.rb` to connect to the PostgreSQL database:

```ruby
# db.rb
require 'sequel'

DB = Sequel.connect('postgres://postgres:postgres@localhost/bookstore')
```

Replace 'postgres:postgres' with your PostgreSQL credentials.


## Create the API
Create a new file called `bookstore_api.rb` and define the BookstoreAPI class:

```ruby
require 'rack'
require 'json'
require_relative 'db'

class BookstoreAPI
  # ...
end
```

Implement the call method to handle incoming requests:

```ruby
def call(env)
  request = Rack::Request.new(env)
  response = Rack::Response.new

  case request.path_info
  when '/books'
    handle_books_request(request, response)
  else
    response.status = 404
    response.write('Not found')
  end

  response.finish
end
```

## Implement the GET and POST endpoints

```ruby
def handle_books_request(request, response)
  case request.request_method
  when 'GET'
    get_books(response)
  when 'POST'
    create_book(request, response)
  else
    response.status = 405
    response.write('Method not allowed')
  end
end

def get_books(response)
  books = DB[:books].all
  response.write(books.to_json)
end

def create_book(request, response)
  # ...
end
```

Implement the `create_book` method to handle book creation:

```ruby
def create_book(request, response)
  book_data = JSON.parse(request.body.read)
  title = book_data['title']
  author = book_data['author']
  published_date = book_data['published_date']
  price = book_data['price']

  if title && author
    book_id = DB[:books].insert(title: title, author: author, published_date: published_date, price: price)
    response.status = 201
    response.write({ id: book_id }.to_json)
  else
    response.status = 400
    response.write('Invalid book data')
  end
end
```
 
## Set up Rack

Create a `config.ru` file to run the Rack application:

```ruby
# config.ru
require_relative 'bookstore_api'

run BookstoreAPI.new
```

## Create scripts to manage the database

Add a script to delete the database in `delete_db.rb`:

```ruby
# delete_db.rb
require 'pg'

def delete_database
  conn = PG.connect(dbname: 'postgres', user: 'postgres', password: 'postgres', host: 'localhost')
  result = conn.exec("SELECT 1 FROM pg_database WHERE datname = 'bookstore';")

  unless result.any?
    puts "Database doesn't exist"
    return
  end

  conn.exec("DROP DATABASE IF EXISTS bookstore;")
  puts "Db successfully dropped"
  conn.close
end

delete_database
```

## Running the scripts

Run the `setup_db` script in order to create the `bookstore` database and the `books` table.

```bash
ruby setup_db.rb
```

You can also run the `delete_db.rb` script if you wish to delete the database. Additionally, you can add to the `delete_db.rb` script if you only wish to get rid of individual tables.

## Run the API

To start the Rack server, run the following command:

```bash
rackup
```

The API should now be running on `http://localhost:9292`. You can use a tool like **Postman** or **curl** to test your endpoints. 
Your `POST` request should look as follows:

```json
{
    "title": "Animal Farm",
    "author": "George Orwell",
    "published date": "27-08-1945",
    "price": 22.5
}
```

When you click on the `GET` endpoint for `/posts`, you should be able to set an array that includes the newly created record.

## Let's refactor

As we can see, our `create_book` method in the bookstore api has gone on a bit long. Let's clean this up a bit.

To begin, let's create a method to parse the request data.


```ruby
def parse_request_body(request)
  JSON.parse(request.body.read)
end
```

We'll add another method to check if the request data is valid. Our API expects the `title` and `author` attributes to be present. 

```ruby
def valid_book_data?(book_data)
  book_data['title'] && book_data['author']
end
```

We will then insert the request data into the database using **Sequel**.

```ruby
def insert_book_into_database(book_data)
  DB[:books].insert(
    title: book_data['title'],
    author: book_data['author'],
    published_date: book_data['published_date'],
    price: book_data['price']
  )
end

```

Finally, we will create methods to send valid / invalid responses.

```ruby
def send_created_response(response, book_id)
  response.status = 201
  response.write({ id: book_id }.to_json)
end

def send_invalid_book_data_response(response)
  response.status = 400
  response.write('Invalid book data')
end
```

Our final code for the `bookstore_api` should look like this.

```ruby
require 'rack'
require 'json'
require_relative 'db'

class BookstoreAPI
  def call(env)
    request = Rack::Request.new(env)
    response = Rack::Response.new

    case request.path_info
    when '/books'
      handle_books_request(request, response)
    else
      response.status = 404
      response.write('Not found')
    end

    response.finish
  end

  private

  def handle_books_request(request, response)
    case request.request_method
    when 'GET'
      get_books(response)
    when 'POST'
      create_book(request, response)
    else
      response.status = 405
      response.write('Method not allowed')
    end
  end

  def get_books(response)
    books = DB[:books].all
    response.write(books.to_json)
  end

  def create_book(request, response)
    book_data = parse_request_body(request)
  
    if valid_book_data?(book_data)
      book_id = insert_book_into_database(book_data)
      send_created_response(response, book_id)
    else
      send_invalid_book_data_response(response)
    end
  end
  
  def parse_request_body(request)
    JSON.parse(request.body.read)
  end
  
  def valid_book_data?(book_data)
    book_data['title'] && book_data['author']
  end
  
  def insert_book_into_database(book_data)
    DB[:books].insert(
      title: book_data['title'],
      author: book_data['author'],
      published_date: book_data['published_date'],
      price: book_data['price']
    )
  end
  
  def send_created_response(response, book_id)
    response.status = 201
    response.write({ id: book_id }.to_json)
  end
  
  def send_invalid_book_data_response(response)
    response.status = 400
    response.write('Invalid book data')
  end
end

```

## Let's write some specs

Add the following gems to your `Gemfile`

```ruby
gem 'rspec'
gem 'rack-test'
```

Add a `bookstore_api_spec.rb` file to test your API:

```ruby
require 'rspec'
require 'rack/test'
require_relative 'bookstore_api'

RSpec.describe BookstoreAPI do
  include Rack::Test::Methods

  def app
    BookstoreAPI.new
  end

  describe 'GET /books' do
    before do
      # Clear the books table and insert test data
      DB[:books].truncate
      DB[:books].insert(title: 'The Catcher in the Rye', author: 'J.D. Salinger', published_date: '1951-07-16', price: 10.99)
      DB[:books].insert(title: 'To Kill a Mockingbird', author: 'Harper Lee', published_date: '1960-07-11', price: 12.99)
    end

    it 'returns a list of books' do
      get '/books'

      expect(last_response).to be_ok
      books = JSON.parse(last_response.body)
      expect(books.size).to eq(2)
    end
  end

  describe 'POST /books' do
    let(:valid_book_data) do
      {
        'title' => '1984',
        'author' => 'George Orwell',
        'published_date' => '1949-06-08',
        'price' => 14.99
      }
    end

    let(:invalid_book_data) do
      {
        'title' => 'The Great Gatsby',
        'published_date' => '1925-04-10',
        'price' => 10.99
      }
    end

    it 'creates a book with valid data' do
      post '/books', valid_book_data.to_json, { 'CONTENT_TYPE' => 'application/json' }

      expect(last_response.status).to eq(201)
      book_id = JSON.parse(last_response.body)['id']
      expect(book_id).not_to be_nil

      book = DB[:books].where(id: book_id).first
      expect(book[:title]).to eq(valid_book_data['title'])
      expect(book[:author]).to eq(valid_book_data['author'])
    end

    it 'returns an error with invalid data' do
      post '/books', invalid_book_data.to_json, { 'CONTENT_TYPE' => 'application/json' }

      expect(last_response.status).to eq(400)
      expect(last_response.body).to eq('Invalid book data')
    end
  end
end
```

Run the specs with the following command.

```bash
rspec .
```

All specs should be passing.

Our final tree structure should look as follows. Only 8 files for an API - pretty neat, right!?!

```bash
├── Gemfile
├── Gemfile.lock
├── bookstore_api.rb
├── bookstore_api_spec.rb
├── config.ru
├── db.rb
├── delete_db.rb
└── setup_db.rb
```

In this article, we built a simple Bookstore API using Rack and PostgreSQL. We created two endpoints for getting and adding books and provided scripts to manage the database. With this foundation, you can extend the API to include more functionality like updating or deleting books, and even add authentication.