Oh yeah. Now we’re going hardcore. Well, not really.
While Rails embraces a continuously changing data model, it’s always good to stop and think for a minute about the domain we’re trying to magically lockwithin an app. We’re building a bookstore. So what’s in one?
This is an onging series of articles. It’s highly recommended you start with part 0!
Books
There are books in a bookstore. Books have titles and authors at a minimum. We want to sell those books, so they probably have prices, also. This gives us one very important information: we’re going to be dealing with money!
One might think that the most natural way to store prices is in a float column - it makes sense, money is floating point after all, right? Not necessarily. When we start thinking about currencies, exchange rates and similar things, it can quickly make one’s head spin. The safest way to store price data is in a dedicated column, where it’s stored as 1/100s of the base currency, and of course to set the base currency first.
Thankfully, we have the lovely money-rails
gem to take care of that for us. Let’s drop it in right now into our Gemfile and install with bundle install
. Afterwards it will need to be initialized with
rails g money_rails:initializer
This will create a money.rb
initializer. For now, we can drop in the simplest of configs there:
MoneyRails.configure do |config|
config.default_currency = :usd
end
Authors
What about authors, what do they have? Definitely names. Also an author can have many books, and books belong to an author.
Well, that was easy. 😀
Scaffolding
Armed with that, we can create ourselves some models. We’ll use Rails’ scaffolding feature for that.
rails g scaffold Author name:string
rails g scaffold Book author:belongs_to title:string price:monetize
Notice how we can use monetize
now as a field type? That’s all money-rails
.
Of course, the generators aren’t as smart as we are, so we need to go in and fix a couple of things.
Routes
Our books cannot exist without authors. We can decide that we’ll only browse them in this context.
Rails.application.routes.draw do
resources :authors do
resources :books
end
root to: 'authors#index'
end
This will also make for some slightly more challenging routing on the frontend side of things - since we’re here to learn, that’s a good thing!
Controllers
This also means that we’ll want to modify the BooksController
:
# frozen_string_literal: true
class BooksController < ApplicationController
before_action :set_author, only: [:show, :update, :destroy]
before_action :set_book, only: [:show, :update, :destroy]
def index
@books = @author.books.all
render json: @books
end
# ...
def create
@book = @author.books.new(book_params)
# ...
end
# ...
private
def set_book
@book = @author.books.find(params[:id])
end
def set_author
@author = Author.find(params[:author_id])
end
# ...
end
I’ve obviously ommited some parts for brevity. The gist of the changes is that we want to generate our @book
with regard to
the author whom we’re browsing.
Models
Some finishing touches to be done here.
Author should be aware that he has books and must have a name. We can also define a to_s
method - it’ll come in handy later.
class Author < ApplicationRecord
has_many :books
validates :name, presence: true
def to_s
name
end
end
Similarly, we can define validations and to_s
on Book:
class Book < ApplicationRecord
belongs_to :author
monetize :price_cents
validates :title, presence: true
def to_s
title
end
end
Seeding
I’ve decided that instead of typing out a bunch of books, I can just import a Goodreads list. I chose one I thought funny - http://www.goodreads.com/list/show/643.Guilty_Pleasures_Crap_You_re_Embarrassed_to_Love - and wrote a simple Rake task to parse it using Nokogiri. It’s placed in lib/tasks/import.rake
and called with rake import:goodreads
.
# frozen_string_literal: true
require 'nokogiri'
require 'open-uri'
GOODREADS_URL = 'http://www.goodreads.com/list/show/643.Guilty_Pleasures_Crap_You_re_Embarrassed_to_Love'.freeze
namespace :import do
task goodreads: :environment do
doc = Nokogiri::HTML(open(GOODREADS_URL))
books = doc.xpath('//*[@itemtype="http://schema.org/Book"]')
books.each { |book| book_from_xml(book) }
end
def book_from_xml(book)
title, author_name = book.xpath('.//*[@itemprop="name"]').map(&:content)
author = Author.find_or_create_by(name: author_name)
book = create_book(author, title)
puts "#{book.author} - #{book} for #{book.price}"
end
def create_book(author, title)
book = author.books.find_or_create_by(title: title)
book.price ||= rand * 15
book.save
book
end
end
Quick sanity check: is it even responding?
Let’s see if our app is responding at all. We can use Postman for that - it’s a very versatile tool and I highly recommend it.
Anyway, let’s boot up our app with rails s
, point Postman at http://localhost:3000
, and…
Woohoo!
Room for improvement
You’ll notice I often discuss this: where can we improve? What can be added or changed in the future? Which improvements we need for a minimum viable product, and which can be saved for later?
I think that the list of things we need for MVP looks something like this:
- Reorganize our controllers a bit more, so that we have our API versioned in the url (
/v1/books
instead of simply/books
). his will allow us to iterate on the API and front end independently. - Locking it down. We should have users and a permissions system (at a minimum, a method to say who is an admin and who is not). Actions such as creating Authors or deleting Books need to be authenticated-admin-only.
Nice to have:
- Images for the books! Our bookstore will look a bit bland, don’t you think?
- A simulated “order” system so the “store” part actually has some sense.
We’ll dig into versioning APIs - the how and the why - in part 3 on Tuesday, July 18th, and into auth a bit later. In the meantime, you can check out the repo at this tag: https://github.com/paweljw/bookstore-backend/tree/part-2 to see the complete code.
See you next time!
Top image credit: https://pixabay.com/p-1862109/ (CC0 Public Domain)