Alexandria is looking good! It’s almost ready to be released - people can sign up, log in, and browse books. But they can’t buy anything yet - that’s kind of problematic for an e-commerce website.
Luckily, that’s a feature we are about to add. By adding prices to books and adding a bunch of new controllers, people will be able to buy books and download them.
Before people can buy anything, we need to add a new attribute to our books: a price! We will get some help from a very nice gem that encapsulate all the logic to handle money, (aptly named Money), and made easy to use with Rails with the money-rails gem.
First, add the gem to your Gemfile
.
# Gemfile
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby '2.5.0'
gem 'rails', '5.2.0'
gem 'pg'
gem 'puma', '~> 3.11'
gem 'bootsnap', '>= 1.1.0', require: false
gem 'carrierwave'
gem 'carrierwave-base64'
gem 'pg_search'
gem 'kaminari'
gem 'bcrypt', '~> 3.1.7'
gem 'pundit'
gem 'money-rails', '1.11.0'
# Hidden Code
Get it installed with bundle
.
bundle install
Next, run the money-rails
generator to create the configuration file.
rails g money_rails:initializer
Output
Running via Spring preloader in process 16821
create config/initializers/money.rb
This created a new initializer, config/initializers/money.rb
. We only need to support USD
in Alexandria, so let’s update the configuration contained in this initializer.
# config/initializers/money.rb
MoneyRails.configure do |config|
config.default_currency = :usd
end
Now, let’s generate a new migration to add the price to our books. We also want to add a field named download_url
that will be used to retrieve the book and send it to the user after purchase.
rails g migration AddPriceAndDownloadUrlToBooks
Here is the migration:
# db/migrate/TIMESTAMP_add_price_and_download_url_to_books.rb
class AddPriceAndDownloadUrlToBooks < ActiveRecord::Migration[5.2]
def change
add_monetize :books, :price
add_column :books, :download_url, :text
end
end
Run it for the development
and test
environments.
rails db:migrate && RAILS_ENV=test rails db:migrate
Output
== 20160614070126 AddPriceAndDownloadUrlToBooks: migrating ====================
-- add_column(:books, "price_cents", :integer, {:null=>false, :default=>0})
-> 0.0089s
-- add_column(:books, "price_currency", :string, {:null=>false, :default=>"USD"})
-> 0.0050s
-- add_column(:books, :download_url, :text)
-> 0.0006s
== 20160614070126 AddPriceAndDownloadUrlToBooks: migrated (0.0150s) ===========
Next, let’s tell the Book
model that it now has a price with the monetize
method provided by money-rails
.
class Book < ApplicationRecord
include PgSearch
multisearchable against: [:title, :subtitle, :description]
monetize :price_cents
# ...
end
Thanks to this method, we now have a bunch of helper methods to handle the price and its currency.
Before we proceed it would be nice if all our books actually had a price. Since this is going to be a one-time task, we can just do it manually with the rails console instead of creating a rake task.
Start the console…
rails c
and update all the books with a price of $2.99, or 299 cents in the format that money-rails
expects.
Book.update_all(price_cents: 299)
Running via Spring preloader in process 9214
Loading development environment (Rails 5.x.x)
2.5.0 :001 > Book.update_all(price_cents: 299)
SQL (21.9ms) UPDATE "books" SET "price_cents" = 299
=> 997
Type exit
to leave the console.
The Book
model is now ready, but we still have to update its presenter to reflect the new attribute. Add price
to the BookPresenter
class. We don’t want to add the download_url
attribute here, because it’s not meant for the client or user to see. This is something we will use later on to generate an expiring download link.
# app/presenters/book_presenter.rb
class BookPresenter < BasePresenter
build_with :id, :title, :subtitle, :isbn_10, :isbn_13, :description,
:released_on, :publisher_id, :author_id, :created_at, :updated_at,
:cover, :price_cents, :price_currency
related_to :publisher, :author
sort_by :id, :title, :released_on, :created_at, :updated_at, :price_cents,
:price_currency
filter_by :id, :title, :isbn_10, :isbn_13, :released_on, :publisher_id,
:author_id, :price_cents, :price_currency
def cover
path = @object.cover.url.to_s
path[0] = '' if path[0] == '/'
"#{root_url}#{path}"
end
end
Now that we’ve assigned prices to the books, people need to be able to make purchases.
Purchase
ModelEvery time someone buys a book, a purchase will be created. We are not going to use carts, users have to buy books individually. After a purchase is created, a service object will take care of verifying it with Stripe. We could use any other payment gateway, but Stripe is easy to operate and widely used; this makes it a very good choice for Alexandria.
Purchase
ModelThe Purchase
model needs the following fields.
id
book_id
user_id
price
idempotency_key
status
charge_id
error
created_at
updated_at
The idempotency_key
is used to ensure that Stripe doesn’t bill the same purchase twice. This is a unique identifier for a purchase made by a user.
Generate this model, the migration file, and the tests with the command below.
rails g model Purchase book:references user:references price:money \
idempotency_key:string status:integer charge_id:string error:text
Output
Running via Spring preloader in process 24343
invoke active_record
create db/migrate/20160613030835_create_purchases.rb
create app/models/purchase.rb
invoke rspec
create spec/models/purchase_spec.rb
invoke factory_bot
create spec/factories/purchases.rb
First, we want indexes on the foreign keys book_id
and user_id
. To make those indexes, we are going to use a composite index for [:user_id, :book_id]
. This index will let us perform fast queries on user_id
or user_id
AND book_id
. We also want an index for book_id
to get all the purchases for a specific book.
# db/migrate/TIMESTAMP_create_purchases.rb
class CreatePurchases < ActiveRecord::Migration[5.2]
def change
create_table :purchases do |t|
t.references :book, foreign_key: true, index: true
t.references :user, foreign_key: true
t.monetize :price
t.string :idempotency_key
t.integer :status, default: 0
t.string :charge_id
t.string :token
t.text :error, default: '{}', null: false
t.timestamps
end
add_index :purchases, [:user_id, :book_id]
end
end
Run this new migration.
rails db:migrate && RAILS_ENV=test rails db:migrate
Let’s update the purchases
factory to include the idempotency_key
.
# spec/factories/purchases.rb
FactoryBot.define do
factory :purchase do
book
user
idempotency_key { '12345' }
token { '123' }
end
end
Before we implement the Purchase
model, let’s define our expectations for it. When a user buys a book, we want to generate a purchase that will link that book to the user. The purchase should be created right away with a pending
status. Once we’ve sent the request to Stripe, the status can change to sent
before becoming either confirmed
or rejected
. The idempotency key will be generated automatically before the record is saved. We also want to copy the price from the book and keep it in the purchase if the price of the book changes later (in case of a discount, price increase, etc).
In the tests, we obviously want a bunch of validations for the mandatory attributes. We also want to test two methods that we will implement that will be responsible for confirming or rejecting a purchase, depending on the outcome of the communication with Stripe.
# spec/models/purchase_spec.rb
require 'rails_helper'
RSpec.describe Purchase, :type => :model do
let(:purchase) { build(:purchase) }
let(:saved_purchase) { create(:purchase) }
it { should validate_presence_of(:price_cents) }
it { should validate_presence_of(:book) }
it { should validate_presence_of(:user) }
it { should validate_presence_of(:token) }
it 'has a valid factory' do
expect(purchase).to be_valid
end
it 'generates an access token before saving' do
# Stub Time.now since it's used to generate the idempotency key
@time_now = Time.parse("Apr 28 2016")
allow(Time).to receive(:now).and_return(@time_now)
purchase.save
expect(purchase.idempotency_key).to eq(
"#{@time_now}/#{purchase.user.id}/#{purchase.book.id}")
end
it 'adds the price before saving' do
purchase.save
expect(purchase.price).to eq(purchase.book.price)
end
describe '#confirm!' do
before { saved_purchase.confirm!('123') }
it 'saves the charge_id' do
expect(saved_purchase.charge_id).to eq '123'
end
it 'confirms the purchase' do
expect(saved_purchase.status).to eq 'confirmed'
end
end
describe '#error!' do
before { saved_purchase.error!({ 'something' => 'went wrong' }) }
it 'registers an error' do
expect(saved_purchase.error).to eq({ 'something' => 'went wrong' })
end
it 'rejects the purchase' do
expect(saved_purchase.status).to eq 'rejected'
end
end
end
Run the tests. They should be failing for now.
rspec spec/models/purchase_spec.rb
Failure (RED)
...
Finished in 0.52071 seconds (files took 2.13 seconds to load)
11 examples, 10 failures
...
Let’s implement the Purchase
model to make them pass.
# app/models/purchase.rb
class Purchase < ApplicationRecord
belongs_to :book
belongs_to :user
before_save :generate_idempotency_key
before_save :set_price
store :error
monetize :price_cents
enum status: { created: 0, sent: 1, confirmed: 2, rejected: 3 }
validates :price_cents, presence: true
validates :book, presence: true
validates :user, presence: true
validates :token, presence: true
def confirm!(charge_id)
confirmed!
update_column :charge_id, charge_id
end
def error!(error)
rejected!
update_column :error, error
end
private
def generate_idempotency_key
self.idempotency_key = "#{Time.now}/#{user.id}/#{book.id}"
end
def set_price
self.price = book.price
end
end
Run the tests to ensure that the Purchase
model was correctly implemented.
rspec spec/models/purchase_spec.rb
Success (GREEN)
...
Purchase
should validate that :price_cents cannot be empty/falsy
should validate that :book cannot be empty/falsy
should validate that :user cannot be empty/falsy
should validate that :token cannot be empty/falsy
has a valid factory
generates an access token before saving
adds the price before saving
#confirm!
saves the charge_id
confirms the purchase
#error!
registers an error
rejects the purchase
Finished in 0.57223 seconds (files took 2.35 seconds to load)
11 examples, 0 failures
Looks good… now, let’s move on to the presenter.
PurchasePresenter
ClassThis is going to be quick. Create a new file for the PurchasePresenter
class…
touch app/presenters/purchase_presenter.rb
and put the following in it. Once again, it’s a pretty generic presenter. We allow all the attributes to be used for the representations.
# app/presenters/purchase_presenter.rb
class PurchasePresenter < BasePresenter
build_with :id, :book_id, :user_id, :price_cents, :price_currency,
:idempotency_key, :status, :charge_id, :error, :created_at,
:updated_at
related_to :user, :book
sort_by :id, :book_id, :user_id, :price_cents, :price_currency, :status,
:created_at, :updated_at
filter_by :id, :book_id, :user_id, :price_cents, :price_currency,
:idempotency_key, :status, :charge_id, :error, :created_at,
:updated_at
end
Purchases are ready! Now it’s time to integrate Stripe and create our first “connector” class.
Let’s run all the tests to ensure that we didn’t break anything.
rspec
Success (GREEN)
...
Finished in 7.48 seconds (files took 1.14 seconds to load)
297 examples, 0 failures
The first thing we are going to do is sign up for a Stripe account. Once it’s done, we will have a test API key that we will use to call Stripe’s API.
Head to Stripe’s Sign Up Page and enter your details to create an account. See Figure 1 for reference.
Click on “Developers” in the sidebar, then “API Keys”. Checkout your API key in the “Secret Key” field. Copy/paste it somewhere, as we are going to need it soon. See Figure 2 for reference.
You should have received an email from Stripe to confirm your email address. You will also need to add a phone number in order to send credit card numbers from Alexandria.
With our Stripe API key in the pocket, we can now configure our application to use Stripe for payments. Stripe provides a Ruby gem to use its API, so let’s add it to our Gemfile
.
# Gemfile
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby '2.5.0'
gem 'rails', '5.2.0'
gem 'pg'
gem 'puma', '~> 3.11'
gem 'bootsnap', '>= 1.1.0', require: false
gem 'carrierwave'
gem 'carrierwave-base64'
gem 'pg_search'
gem 'kaminari'
gem 'bcrypt', '~> 3.1.7'
gem 'pundit'
gem 'money-rails', '1.11.0'
gem 'stripe'
# Hidden Code
Get it installed with bundle
.
bundle install
We need a place to store the API key. Since this is sensitive information, we cannot put it anywhere in our versioned code. In production, we will be able to define a bunch of environment variables wherever we deploy the application. For development, let’s create a file that won’t be versioned where we can store our environment variables.
Create a new file in the config/
folder.
touch config/env.rb
Add this new file to the .gitignore
file so it will be ignored by Git.
# See https://help.github.com/articles/ignoring-files for more about ignoring files.
#
# If you find yourself ignoring temporary files generated by your text editor
# or operating system, you probably want to add a global ignore instead:
# git config --global core.excludesfile '~/.gitignore_global'
# Ignore bundler config.
/.bundle
# Ignore the default SQLite database.
/db/*.sqlite3
/db/*.sqlite3-journal
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
# Ignore uploaded files in development
/storage/*
.byebug_history
# Ignore master key for decrypting credentials and more.
/config/master.key
/public/uploads
/images
/config/env.rb
Finally, put the API key you got from Stripe in the env.rb
file using the same name as in the code below (STRIPE_API_KEY
).
# config/env.rb
ENV['STRIPE_API_KEY'] = 'YOUR_API_KEY'
For the last step, we need to to load the content of the env.rb
file as early as possible. Let’s add this code:
env_variables = File.join('config', 'env.rb')
load(env_variables) if File.exists?(env_variables)
In the application.rb
file.
# config/application.rb
require_relative 'boot'
require "rails"
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_view/railtie"
require "action_cable/engine"
require "rails/test_unit/railtie"
require 'carrierwave'
require 'carrierwave/orm/activerecord'
Bundler.require(*Rails.groups)
env_variables = File.join('config', 'env.rb')
load(env_variables) if File.exists?(env_variables)
module Alexandria
class Application < Rails::Application
config.load_defaults 5.2
config.api_only = true
config.filter_parameters += [:cover]
end
end
Alright, this looks good to go. We’ve just signed up with Stripe and our application is configured to use the correct API key. Now, let’s prepare our tests implementation by adding a new gem: VCR.
Stubbing calls to external services is a pain. Plus, the responses they provide can change which requires changing the way we stub. Instead, I prefer to use gems like VCR that will record HTTP interactions and replay them when needed. The first time, it will actually allow the test to make a request. Any future test run will use a cached and local version that VCR generated for us.
You will understand more as we progress through this section. For now, add the vcr
gem to your Gemfile
, in the :development, :test
group.
# Gemfile
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby '2.5.0'
gem 'rails', '5.2.0'
gem 'pg'
gem 'puma', '~> 3.11'
gem 'bootsnap', '>= 1.1.0', require: false
gem 'carrierwave'
gem 'carrierwave-base64'
gem 'pg_search'
gem 'kaminari'
gem 'bcrypt', '~> 3.1.7'
gem 'pundit'
gem 'money-rails', '1.11.0'
gem 'stripe'
group :development, :test do
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
gem 'rspec-rails'
gem 'factory_bot_rails'
gem 'vcr'
end
group :development do
gem 'listen', '>= 3.0.5', '< 3.2'
gem 'spring'
gem 'spring-watcher-listen', '~> 2.0.0'
end
group :test do
gem 'shoulda-matchers'
gem 'webmock'
gem 'database_cleaner'
end
Install it with bundle
.
bundle install
Before we can use it in our tests, we need to configure it in the rails_helper
file. Simply append the code below at the end of the file. In it, we set the configuration for vcr
, including where we want the cassettes to be stored. After that, we should be able to use the vcr
gem!
# spec/rails_helper.rb
# Hidden Code
VCR.configure do |config|
config.cassette_library_dir = 'spec/vcr_cassettes'
config.hook_into :webmock
end
Want to give it a try? Let’s write the tests for the StripeConnector
class then!
First, what’s this connector class? Well, it’s the class that will be responsible for communicating with Stripe and that’s it. Instead of making external calls from a model or a controller, we encapsulate this logic in one dedicated class.
Generate the files for the Stripe connector.
mkdir app/connectors spec/connectors && \
touch app/connectors/stripe_connector.rb \
spec/connectors/stripe_connector_spec.rb
Here, we’ll make an exception and write the code before the tests. This is because the StripeConnector
class has a bunch of new stuff that we need to go through. Afterwards, the tests will be much easier to understand.
This connector will be used in the purchases
controller in the following way:
StripeConnector.new(purchase).charge
The purchase
object should contain all the required information to charge the user. The charge
method will let the stripe
gem make the actual call to the Stripe API. Read the code and the comments below to get a better sense of it.
This connector was built based on the documentation of the Stripe API available here.
# app/connectors/stripe_connector.rb
class StripeConnector
def initialize(purchase)
@purchase = purchase
# We need to set the API key if it hasn't been
# set yet
Stripe.api_key ||= ENV['STRIPE_API_KEY']
end
def charge
@purchase.sent!
create_charge
@purchase
end
private
def create_charge
begin
# Let's get some money!
charge = Stripe::Charge.create(stripe_hash, {
idempotency_key: @purchase.idempotency_key
})
# No error raised? Let's confirm the purchase.
@purchase.confirm!(charge.id)
charge
rescue Stripe::CardError => e
# If we get an error, we save it in the purchase.
# The controller can then send it back to the client.
body = e.json_body
@purchase.error!(body[:error])
body
end
end
# Here we build the hash that will get submitted to
# Stripe.
def stripe_hash
{
amount: @purchase.price.fractional,
currency: @purchase.price.currency.to_s,
source: @purchase.token,
metadata: { purchase_id: @purchase.id },
description: description
}
end
def description
"Charge for #{@purchase.book.title} (Purchase ID #{@purchase.id})"
end
end
Now let’s write some tests! We are going to test two contexts: with a valid card and with an invalid one. For a real application, we should probably write more tests and check more Stripe errors - but for Alexandria, this will do.
We are going to use the vcr
gem to record the requests. This will allow us to re-run the tests super fast and as many times as we want without worrying about the network.
A test using vcr
should include the following code:
VCR.use_cassette('cool_cassette_name') do
# HTTP Interactions
end
Any HTTP request in this block will be executed the first time and cached for future reruns. You can always delete the VCR cassettes folder (spec/vcr_cassettes
) to start fresh.
Here are the tests for the StripeConnector
class.
# spec/connectors/stripe_connector_spec.rb
require 'rails_helper'
RSpec.describe StripeConnector do
before(:all) { Stripe.api_key ||= ENV['STRIPE_API_KEY'] }
let(:book) { create(:book, price_cents: 299) }
let(:purchase) { create(:purchase, book: book) }
def charge_with_token(purchase, card)
token = Stripe::Token.create(card: card)
purchase.update_column :token, token['id']
StripeConnector.new(purchase).send(:create_charge)
end
def card(number)
{ number: number, exp_month: 6, exp_year: 2028, cvc: "314" }
end
context 'with valid card' do
let(:valid_card) { card('4242424242424242') }
it 'succeeds' do
VCR.use_cassette('stripe/valid_card') do
charge = charge_with_token(purchase, valid_card)
expect(charge['status']).to eq 'succeeded'
expect(purchase.reload.charge_id).to eq charge['id']
expect(purchase.reload.status).to eq 'confirmed'
end
end
end
context 'with invalid card' do
let(:invalid_card) { card('4000000000000002') }
it 'declines the card' do
VCR.use_cassette('stripe/invalid_card') do
charge = charge_with_token(purchase, invalid_card)
expect(charge[:error][:code]).to eq 'card_declined'
expect(purchase.reload.error).to eq charge[:error].stringify_keys
expect(purchase.reload.status).to eq 'rejected'
end
end
end
end
Run the tests, just to be safe.
rspec spec/connectors/stripe_connector_spec.rb
Success (GREEN)
...
StripeConnector
with valid card
succeeds
with invalid card
declines the card
Finished in 0.3776 seconds (files took 2.6 seconds to load)
2 examples, 0 failures
Awesome! Before we attack the purchases
controller, let’s define the purchase permissions with the PurchasePolicy
class.
We can now communicate with Stripe. The missing bit is an actual controller to call the StripeConnector
from. Before we create that controller, let’s define the policies of purchases.
Create the needed files with this command.
touch app/policies/purchase_policy.rb \
spec/policies/purchase_policy_spec.rb
You should be quite familiar with how policy tests have to be written by now, but this policy is a bit different. The index
action will be used to send either all purchases (if the user is an admin), or only the purchases that this user made (if he is not an admin).
To achieve this, we will be using Pundit
policy scopes - an example is available below. This code should be included inside the policy and we will then use it in our controller to ensure that users don’t get access to something they should not.
class Scope
attr_reader :user, :scope
def initialize(user, scope)
@user = user
@scope = scope
end
def resolve
if user.admin?
scope.all
else
scope.where(user_id: user.id)
end
end
end
You should be quite familiar with how policy tests have to be written by now, but this policy is a bit different. The index action will be used to send either all purchases (if the user is an admin), or only the purchases that this user made (if he is not an admin).
We also added tests for index?
and create?
that everyone can access. The show
action is open for admins but limited to purchases they made for regular users.
# spec/policies/purchase_policy_spec.rb
require 'rails_helper'
describe PurchasePolicy do
subject { described_class }
describe '.scope' do
let(:admin) { create(:admin) }
let(:user) { create(:user) }
let(:rails_tuto) { create(:ruby_on_rails_tutorial) }
let(:ruby_micro) { create(:ruby_microscope) }
let(:purchase_admin) { create(:purchase, user: admin, book: ruby_micro) }
let(:purchase_user) { create(:purchase, user: user, book: rails_tuto) }
before { purchase_admin && purchase_user }
context 'when admin' do
let(:scope) { PurchasePolicy::Scope.new(admin, Purchase.all).resolve }
it 'gets all the purchases' do
expect(scope).to include(purchase_admin)
expect(scope).to include(purchase_user)
end
end
context 'when regular user' do
let(:scope) { PurchasePolicy::Scope.new(user, Purchase.all).resolve }
it 'gets all the purchases that belong to the user' do
expect(scope).to_not include(purchase_admin)
expect(scope).to include(purchase_user)
end
end
end
permissions :index?, :create? do
it 'grants access' do
expect(subject).to permit(User.new, Purchase)
end
end
permissions :show? do
context 'when regular user' do
it 'denies access if the user and record owner are different' do
expect(subject).not_to permit(User.new, Purchase.new)
end
it 'grants access if the user and record owner are the same' do
user = User.new
expect(subject).to permit(user, Purchase.new(user: user))
end
end
context 'when admin' do
it 'grants access' do
expect(subject).to permit(build(:admin), Purchase.new)
end
end
end
end
Now let’s implement the PurchasePolicy
based on those expectations.
# app/policies/purchase_policy.rb
class PurchasePolicy < ApplicationPolicy
def index?
user
end
def show?
user.admin? || record.user == user
end
def create?
user
end
class Scope
attr_reader :user, :scope
def initialize(user, scope)
@user = user
@scope = scope
end
def resolve
if user.admin?
scope.all
else
scope.where(user_id: user.id)
end
end
end
end
Run the tests to ensure that everything is working as expected.
rspec spec/policies/purchase_policy_spec.rb
Success (GREEN)
PurchasePolicy
.scope
when admin
gets all the purchases
when normal user
gets all the purchases that belong to the user
index? and create?
grants access
show?
denies access if user is not admin and the user and record are different
grants access if user is not admin and the user and record are the same
grants access if the user is admin
Finished in 0.42125 seconds (files took 2.72 seconds to load)
6 examples, 0 failures
PurchasesController
Finally, the purchases
controller! Create new files to hold its content and its tests.
touch app/controllers/purchases_controller.rb \
spec/requests/purchases_spec.rb
We then need to add a new resources
in the routes.rb
file.
Add the following to the routes:
resources :purchases, only: [:index, :show, :create]
Here is the complete file for reference.
# config/routes.rb
Rails.application.routes.draw do
scope :api do
resources :books, except: :put
resources :authors, except: :put
resources :publishers, except: :put
resources :users, except: :put
resources :user_confirmations, only: :show, param: :confirmation_token
resources :password_resets, only: [:show, :create, :update],
param: :reset_token
resources :access_tokens, only: :create do
delete '/', action: :destroy, on: :collection
end
resources :purchases, only: [:index, :show, :create]
get '/search/:text', to: 'search#index'
end
root to: 'books#index'
end
index
ActionLet’s add the first action in our controller: index
. This action will simply return a list of purchases and we will be using the policy_scope
method from Pundit
to ensure that users only get what they are allowed to.
We already tested the permissions in the purchase policy tests, so let’s just check that this action returns 200
with one purchase.
# spec/requests/purchases_spec.rb
require 'rails_helper'
RSpec.describe 'Purchases', type: :request do
include_context 'Skip Auth'
before(:all) { Stripe.api_key ||= ENV['STRIPE_API_KEY'] }
let(:book) { create(:ruby_on_rails_tutorial, price_cents: 299) }
let(:purchase) { create(:purchase, book: book) }
describe 'GET /api/purchases' do
before do
purchase
get '/api/purchases'
end
it 'gets HTTP status 200' do
expect(response.status).to eq 200
end
it 'receives the only purchase in the db' do
expect(json_body['data'].size).to eq 1
expect(json_body['data'].first['id']).to eq purchase.id
end
end # describe 'GET /api/purchases' end
end
If you run the tests now, they’ll be failing.
rspec spec/requests/purchases_spec.rb
Failure (RED)
...
Finished in 0.34778 seconds (files took 2.48 seconds to load)
2 examples, 2 failures
...
Let’s fix them by creating the PurchasesController
class and implementing the index
action. We use the orchestrate_query
method in the same way than with our other controllers. This time however, we pass the scope generated by Pundit
with policy_scope(Purchase)
instead of Purchase.all
.
# app/controllers/purchases_controller.rb
class PurchasesController < ApplicationController
before_action :authenticate_user
before_action :authorize_actions
def index
purchases = orchestrate_query(policy_scope(Purchase))
render serialize(purchases)
end
end
Let’s run the tests again.
rspec spec/requests/purchases_spec.rb
Success (GREEN)
...
Purchases
GET /api/purchases
gets HTTP status 200
receives 1 purchase
Finished in 0.38649 seconds (files took 3.58 seconds to load)
2 examples, 0 failures
Great, everything is working. Let’s proceed with another simple action: show
.
show
ActionThis action is going to be exactly like in our other controllers. First, we’ll write some basic tests to ensure that this action returns 200
and the expected purchase.
# spec/requests/purchases_spec.rb
require 'rails_helper'
RSpec.describe 'Purchases', type: :request do
# Hidden Code
describe 'GET /api/purchases' # Hidden Code
describe 'GET /api/purchases/:id' do
context 'with existing resource' do
before { get "/api/purchases/#{purchase.id}" }
it 'gets HTTP status 200' do
expect(response.status).to eq 200
end
it 'receives the purchase as JSON' do
expected = { data: PurchasePresenter.new(purchase, {}).fields.embeds }
expect(response.body).to eq(expected.to_json)
end
end
context 'with nonexistent resource' do
it 'gets HTTP status 404' do
get '/api/purchases/2314323'
expect(response.status).to eq 404
end
end
end # describe 'GET /purchases/:id' end
end
Run the tests to see them fail.
rspec spec/requests/purchases_spec.rb
Failure (RED)
...
Finished in 0.47026 seconds (files took 2.37 seconds to load)
5 examples, 3 failures
Finally, let’s implement the show
action with… one line. We also need to implement the purchase
method that will load the current purchase for us.
# app/controllers/purchases_controller.rb
class PurchasesController < ApplicationController
before_action :authenticate_user
before_action :authorize_actions
def index
purchases = orchestrate_query(policy_scope(Purchase))
render serialize(purchases)
end
def show
render serialize(purchase)
end
private
def purchase
@purchase ||= params[:id] ? Purchase.find_by!(id: params[:id]) :
Purchase.new(purchase_params)
end
alias_method :resource, :purchase
end
How are the tests doing now?
rspec spec/requests/purchases_spec.rb
Success (GREEN)
...
Purchases
GET /api/purchases
gets HTTP status 200
receives 1 purchase
GET /api/purchases/:id
with existing resource
gets HTTP status 200
receives the purchase as JSON
with nonexistent resource
gets HTTP status 404
Finished in 0.52174 seconds (files took 2.39 seconds to load)
5 examples, 0 failures
Neat!
create
ActionThe create
action is the most complex one. This is where we will instantiate the StripeConnector
class. Go through the tests below; I believe the test names are explicit enough to be self-descriptive.
# spec/requests/purchases_spec.rb
require 'rails_helper'
RSpec.describe 'Purchases', type: :request do
# Hidden Code
describe 'GET /api/purchases' # Hidden Code
describe 'GET /api/purchases/:id' # Hidden Code
describe 'POST /api/purchases' do
context 'with valid parameters' do
let(:card) do
{ number: '4242424242424242', exp_month: 6, exp_year: 2028, cvc: "314" }
end
let(:token) { Stripe::Token.create(card: card)['id'] }
let(:params) { attributes_for(:purchase, book_id: book.id, token: token) }
it 'gets HTTP status 201' do
VCR.use_cassette('/api/purchases/valid_params') do
post '/api/purchases', params: { data: params }
expect(response.status).to eq 201
end
end
it 'returns the newly created resource' do
VCR.use_cassette('/api/purchases/valid_params') do
post '/api/purchases', params: { data: params }
expect(json_body['data']['book_id']).to eq book.id
end
end
it 'adds a record in the database' do
VCR.use_cassette('/api/purchases/valid_params') do
post '/api/purchases', params: { data: params }
expect(Purchase.count).to eq 1
end
end
it 'returns the new resource location in the Location header' do
VCR.use_cassette('/api/purchases/valid_params') do
post '/api/purchases', params: { data: params }
expect(response.headers['Location']).to eq(
"http://www.example.com/api/purchases/#{Purchase.first.id}"
)
end
end
end
context 'with invalid parameters' do
let(:params) { attributes_for(:purchase, token: '') }
before { post '/api/purchases', params: { data: params } }
it 'gets HTTP status 422' do
expect(response.status).to eq 422
end
it 'receives an error details' do
expect(json_body['error']['invalid_params']).to eq(
{"book"=>["must exist", "can't be blank"], "token"=>["can't be blank"]}
)
end
it 'does not add a record in the database' do
expect(Purchase.count).to eq 0
end
end # context 'with invalid parameters'
end # describe 'POST /purchases'
end
To make those tests pass, we need to make a little modification in the application controller. We need the unprocessable_entity!
method to be able to optionally receive the errors that will be returned to the client.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
# Hidden Code
protected
def builder_error # Hidden Code
def unprocessable_entity!(resource, errors = nil)
render status: :unprocessable_entity, json: {
error: {
message: "Invalid parameters for resource #{resource.class}.",
invalid_params: errors || resource.errors
}
}
end
def orchestrate_query # Hidden Code
def serialize # Hidden Code
def resource_not_found # Hidden Code
end
With that done, let’s implement the create
action. Note that we will be calling the Stripe API in the request flow, which is far from ideal. This will block the request until Stripe has finished processing our request and replied. A better way of doing it would be to run it in the background with ActiveJob
. Unfortunately, this is out of the scope of this book, so we will have to count on the client to handle this blocking request.
The code below contains comments in the create
action.
# app/controllers/purchases_controller.rb
class PurchasesController < ApplicationController
before_action :authenticate_user
before_action :authorize_actions
def index
purchases = orchestrate_query(policy_scope(Purchase))
render serialize(purchases)
end
def show
render serialize(purchase)
end
def create
# The current_user is making the purchase so let's
# assign it to the purchase object
purchase.user = current_user
if purchase.save
# Let's get some money!
completed_purchase = StripeConnector.new(purchase).charge
# Did something go wrong?
if completed_purchase.error.any?
unprocessable_entity!(completed_purchase, purchase.error)
else
# Let's return the purchase to the client
render serialize(completed_purchase).merge({
status: :created,
location: completed_purchase
})
end
else
unprocessable_entity!(purchase)
end
end
private
def purchase
@purchase ||= params[:id] ? Purchase.find_by!(id: params[:id]) :
Purchase.new(purchase_params)
end
alias_method :resource, :purchase
def purchase_params
params.require(:data).permit(:book_id, :token)
end
end
Run the tests to ensure that we met all our expectations.
rspec spec/requests/purchases_spec.rb
Success (GREEN)
...
Purchases
GET /api/purchases
gets HTTP status 200
receives 1 purchase
GET /api/purchases/:id
with existing resource
gets HTTP status 200
receives the purchase as JSON
with nonexistent resource
gets HTTP status 404
POST /api/purchases
with valid parameters
gets HTTP status 201
returns the newly created resource
adds a record in the database
returns the new resource location in the Location header
with invalid parameters
gets HTTP status 422
receives an error details
does not add a record in the database
Finished in 1.15 seconds (files took 2.45 seconds to load)
12 examples, 0 failures
Awesome! This finalizes the purchase flow.
The following section consists of a theoretical and a practical part. We won’t have a completely functional download system since we have no books and I don’t want to take you through the Amazon S3 setup here.
The final step to Alexandria would be to allow users to download the books they bought. To do this, we need to give a link to the client where the purchased book can be downloaded. We could also send an email to the user with every purchase.
To do those things, we need a downloads
controller that will generate and send back the download link for a specific book.
Let’s add a few more files to put this logic.
touch app/controllers/downloads_controller.rb \
spec/requests/downloads_spec.rb \
app/policies/download_policy.rb \
spec/policies/download_policy_spec.rb
Add a new route, downloads
, in the books
resources block. With this, we will be able to access /books/1/download
.
# config/routes.rb
Rails.application.routes.draw do
scope :api do
resources :books, except: :put do
get :download, to: 'downloads#show'
end
resources :authors, except: :put
resources :publishers, except: :put
resources :users, except: :put
resources :user_confirmations, only: :show, param: :confirmation_token
resources :password_resets, only: [:show, :create, :update],
param: :reset_token
resources :access_tokens, only: :create do
delete '/', action: :destroy, on: :collection
end
resources :purchases, only: [:index, :show, :create]
get '/search/:text', to: 'search#index'
end
root to: 'books#index'
end
To check if the user can download this book, we need to have the list of all the books a specific user has bought. We can do this using some has_many
relationships.
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
has_many :access_tokens
has_many :purchases
has_many :books, through: :purchases
# Hidden Code
The download policy tests are pretty straightforward, since there is only one action (show
) which will return a download link.
# spec/policies/download_policy_spec.rb
require 'rails_helper'
describe DownloadPolicy do
subject { described_class }
permissions :show? do
context 'when admin' do
it 'grants access' do
expect(subject).to permit(build(:admin), Purchase.new)
end
end
context 'when not admin' do
it 'denies access if the user did not buy the book' do
user = create(:user)
expect(subject).not_to permit(user, create(:book))
end
it 'grants access if the user has bought the book' do
user = create(:user)
book = create(:book)
create(:purchase, user: user, book: book)
expect(subject).to permit(user, book)
end
end
end
end
The download policy in all its splendor.
# app/policies/download_policy.rb
class DownloadPolicy < ApplicationPolicy
def show?
user.admin? || user.books.pluck(:id).include?(record.id)
end
end
Run the tests, just to be safe.
rspec spec/policies/download_policy_spec.rb
Success (GREEN)
DownloadPolicy
show?
when admin
grants access
when not admin
denies access if the user didn't buy the book
grants access if the user has bought the book
Finished in 0.28774 seconds (files took 2.89 seconds to load)
3 examples, 0 failures
Now, let’s add the downloads controller.
# app/controllers/downloads_controller.rb
class DownloadsController < ApplicationController
before_action :authenticate_user
def show
authorize(book)
render status: 204, location: book.download_url
end
private
def book
@book ||= Book.find_by!(id: params[:book_id])
end
end
And that’s it. The client can do whatever it wants with this download URL.
Note that this is approach could be improved by sending links that expires after a while. If we were using Amazon S3, we could use a method that connects to S3 and generates a link valid for 10 minutes. We would also need to store the book filename in that bucket instead of the download URL.
Here are some tests for the downloads controller.
# spec/requests/downloads_spec.rb
require 'rails_helper'
RSpec.describe 'Access Tokens', type: :request do
include_context 'Skip Auth'
let(:book) { create(:book, download_url: 'http://example.com') }
describe 'GET /api/books/:book_id/download' do
context 'with an existing book' do
before { get "/api/books/#{book.id}/download" }
it 'returns 204' do
expect(response.status).to eq 204
end
it 'returns the download url in the Location header' do
expect(response.headers['Location']).to eq 'http://example.com'
end
end
context 'with nonexistent book' do
it 'returns 404' do
get '/api/books/123/download'
expect(response.status).to eq 404
end
end
end
end
Run all the tests to ensure that everything is working.
rspec
Success (GREEN)
...
Finished in 13.88 seconds (files took 4.93 seconds to load)
323 examples, 0 failures
Let’s push the changes.
git status
Output
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
modified: .gitignore
modified: Gemfile
modified: Gemfile.lock
modified: app/controllers/application_controller.rb
modified: app/models/book.rb
modified: app/models/user.rb
modified: app/presenters/book_presenter.rb
modified: config/application.rb
modified: config/routes.rb
modified: db/schema.rb
modified: spec/rails_helper.rb
Untracked files:
(use "git add <file>..." to include in what will be committed)
app/connectors/
app/controllers/downloads_controller.rb
app/controllers/purchases_controller.rb
app/models/purchase.rb
app/policies/download_policy.rb
app/policies/purchase_policy.rb
app/presenters/purchase_presenter.rb
config/initializers/money.rb
db/migrate/20160614070126_add_price_and_download_url_to_books.rb
db/migrate/20160614070533_create_purchases.rb
spec/connectors/
spec/factories/purchases.rb
spec/models/purchase_spec.rb
spec/policies/download_policy_spec.rb
spec/policies/purchase_policy_spec.rb
spec/requests/downloads_spec.rb
spec/requests/purchases_spec.rb
spec/vcr_cassettes/
no changes added to commit (use "git add" and/or "git commit -a")
Stage them.
git add .
Commit the changes.
git commit -m "Implement purchases"
Output
[master 141c88f] Implement purchases
33 files changed, 1275 insertions(+), 26 deletions(-)
create mode 100644 app/connectors/stripe_connector.rb
create mode 100644 app/controllers/downloads_controller.rb
create mode 100644 app/controllers/purchases_controller.rb
create mode 100644 app/models/purchase.rb
create mode 100644 app/policies/download_policy.rb
create mode 100644 app/policies/purchase_policy.rb
create mode 100644 app/presenters/purchase_presenter.rb
create mode 100644 config/initializers/money.rb
create mode 100644 db/migrate/20160614070126_add_price_and_download_url_to_books.rb
create mode 100644 db/migrate/20160614070533_create_purchases.rb
create mode 100644 spec/connectors/stripe_connector_spec.rb
create mode 100644 spec/factories/purchases.rb
create mode 100644 spec/models/purchase_spec.rb
create mode 100644 spec/policies/download_policy_spec.rb
create mode 100644 spec/policies/purchase_policy_spec.rb
create mode 100644 spec/requests/downloads_spec.rb
create mode 100644 spec/requests/purchases_spec.rb
create mode 100644 spec/vcr_cassettes/api/purchases/valid_params.yml
create mode 100644 spec/vcr_cassettes/stripe/invalid_card.yml
create mode 100644 spec/vcr_cassettes/stripe/valid_card.yml
Push to GitHub.
git push origin master
This chapter was the last big milestone for Alexandria. In the next chapters, we will work on optimizations and improvements before deploying it live. In the next module, we will come back to it and see why Alexandria is not RESTful, and how we can fix it.