Book a Bookstore API using Rack, Sequel and Postgresql


Created by Stan on 10-04-2023


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:

mkdir bookstore_api
cd bookstore_api

Create a Gemfile to manage your project's dependencies:

# 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:

# 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:

require 'rack'
require 'json'
require_relative 'db'

class BookstoreAPI
  # ...
end

Implement the call method to handle incoming requests:

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

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:

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:

# 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:

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

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:

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:

{
    "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.

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.

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.

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.

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.

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

gem 'rspec'
gem 'rack-test'

Add a bookstore_api_spec.rb file to test your API:

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.

rspec .

All specs should be passing.

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

├── 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.



Related Posts