Note: the slide version of this workshop is available here.
-
This workshop is going to be breaking down the steps of how turn a Rails app into a simple API.
-
We'll be going under the assumption, we've built previous Rails apps before.
-
I suggest to follow the steps to create a new app in the README instead of trying to clone it.
We will build a Rails application that acts solely as an API. Instead of displaying HTML pages, it'll render JSON.
In this separate workshop, we'll build a React application to consume this API.
rails new rails-cafe-api -d postgresql --api
With the --api
flag, there are 3 main differences:
- Configure your application to start with a more limited set of middleware than normal. Specifically, it will not include any middleware primarily useful for browser applications (like cookies support) by default.
- Make
ApplicationController
inherit fromActionController::API
instead ofActionController::Base
. As with middleware, this will leave out any Action Controller modules that provide functionalities primarily used by browser applications. - Configure the generators to skip generating views, helpers, and assets when you generate a new resource.
You can read more about the changes in the official documentation.
Feel free to change the respository name:
gh repo create rails-cafe-api --public --source=.
We're going to keep this tutorial simple. We'll just have a cafe
model. Based around this information, which we'll be seeding into our app eventually.
title
: stringaddress
: stringpicture
: string (⚠️ We're not using ActiveStorage for simplicity sake).hours
: hash (⚠️ see how to create this below)criteria
: array (⚠️ see how to create this below)
Create the DB before the model
rails db:create
- The pluralization is built in to handle things like
person
=>people
andsky
=>skies
etc. - But when we generate a
cafe
model in Rails, it creates a table calledcaves
.... which is obviously not what we want. Here is a StackOverflow answer on how to fix it
So let's go into our config/initializers/inflections.rb
and add this:
ActiveSupport::Inflector.inflections do |inflect|
inflect.plural "cafe", "cafes"
end
Then create the model
rails g model cafe title:string address:string picture:string hours:jsonb criteria:string
You'll notice that when we create the hours
hash, we're actually using a jsonb
type.
You can see how this works in the official documentaion.
And also when we create the criteria
array, we're actually specifying a string at first. But we'll have to update the migration (before we migrate) to indicate we're using an array:
t.string :criteria, array: true
You can see how this works in the official documentaion.
Then run the migration and our DB should be ready to go.
rails db:migrate
It's up to you at this point, but we'll add three validations on the cafe
model so that we need at least a title
and address
in order to create one. And also a uniqueness so that the same cafe at the same address can't be recreated.
# cafe.rb
validates :title, presence: true, uniqueness: { scope: :address }
validates :address, presence: true
We were basing our data on around this information already so we've got a JSON that we can use in our seeds.
- We'll open that link using
open-uri
- Turn the JSON result into a Ruby array
- Iterate over the array and create an instance of a
cafe
for each hash in the array.
The point of this workshop is not how to seed the DB, so the code is already set in our db/seeds.rb
file.
require 'open-uri'
puts "Removing all cafes from the DB..."
Cafe.destroy_all
puts "Getting the cafes from the JSON..."
seed_url = 'https://gist.githubusercontent.com/yannklein/5d8f9acb1c22549a4ede848712ed651a/raw/3daec24bcd833f0dd3bcc8cee8616a731afd1f37/cafe.json'
# Making an HTTP request to get back the JSON data
json_cafes = URI.open(seed_url).read
# Converting the JSON data into a ruby object (this case an array)
cafes = JSON.parse(json_cafes)
# iterate over the array of hashes to create instances of cafes
cafes.each do |cafe_hash|
puts "Creating #{cafe_hash['title']}..."
Cafe.create!(
title: cafe_hash['title'],
address: cafe_hash['address'],
picture: cafe_hash['picture'],
criteria: cafe_hash['criteria'],
hours: cafe_hash['hours']
)
end
puts "... created #{Cafe.count} cafes! ☕️"
Run the seeds rails db:seed
and have a look in the rails console
to see our cafes.
If this is your first time building an API the routing is going to look a bit different from normal CRUD routes inside of a Rails app. We're going to add the word api
in our route but also version it. So that if we end up updating the API, we dont have to break the old flow for apps relying on it. We can just shift to the second version.
So our user stories with routes:
- I can see all cafes
get '/api/v1/cafes'
- I can create a cafe
post '/api/v1/cafes'
How to namespace in our routes.rb
namespace :api, defaults: { format: :json } do
namespace :v1 do
resources :cafes, only: [ :index, :create ]
end
end
Here we're also saying to expect json (since it's an API) instead of the normal HTML flow.
Now we need to create the cafes_controller
but we're going to create one specifically for v1
of our api
. This gives us flexibility later on to create a separate controller for the next version.
To generate
rails g controller api/v1/cafes
This creates our controller. But also, it creates a folder called api
inside of our controllers
folder. Then another one called v1
inside of that.
Let's start with the index. It will follow normal Rails CRUD to pull all of the cafes from the DB.
def index
@cafes = Cafe.all
end
If we allow users to search for cafes by their title
in our app, we can add that into our action as well:
def index
if params[:title].present?
@cafes = Cafe.where('title ILIKE ?', "%#{params[:title]}%")
else
@cafes = Cafe.all
end
end
BUT, this is the biggest difference from building an API compared to one with HTML views. Instead of rendering HTML, we're going to render JSON.
def index
if params[:title].present?
@cafes = Cafe.where('title ILIKE ?', "%#{params[:title]}%")
else
@cafes = Cafe.all
end
# Putting the most recently created cafes first
render json: @cafes.order(created_at: :desc)
end
Now let's test out the endpoint. If we want to see our routes, we can check with rails routes
.
This tells us to trigger our cafes#index
action, we need to type /api/v1/cafes
after our localhost.
Launch a rails s
and check it out in the browser. You should be seeing JSON (intead of HTML).
POST
request instead of a GET
. And we don't have an HTML form either. The easiest way to test this endpoint would be to use Postman. In Postman, we'll need to make sure we're sending a POST
to the correct address, but also sending the correct params.
We'll want our request to look like this:
Or just the request code:
{
"cafe": {
"title": "Le Wagon Tokyo",
"address": "2-11-3 Meguro, Meguro City, Tokyo 153-0063",
"picture": "https://www-img.lewagon.com/wtXjAOJx9hLKEFC89PRyR9mSCnBOoLcerKkhWp-2OTE/rs:fill:640:800/plain/s3://wagon-www/x385htxbnf0kam1yoso5y2rqlxuo",
"criteria": ["Stable Wi-Fi", "Power sockets", "Coffee", "Food"],
"hours": {
"Mon": ["10:30 – 18:00"],
"Tue": ["10:30 – 18:00"],
"Wed": ["10:30 – 18:00"],
"Thu": ["10:30 – 18:00"],
"Fri": ["10:30 – 18:00"],
"Sat": ["10:30 – 18:00"]
}
}
}
Our create action is going to look exactly like a normal CRUD create action, except for when an error occurs. Instead of rerendering a form like we would in HTML, we'll respond back with the error inside of the JSON response:
render json: { error: @cafe.errors.messages }, status: :unprocessable_entity
So our full create
controller action will look something like:
def create
@cafe = Cafe.new(cafe_params)
if @cafe.save
render json: @cafe, status: :created
else
render json: { error: @cafe.errors.messages }, status: :unprocessable_entity
end
end
private
def cafe_params
params.require(:cafe).permit(:title, :address, :picture, hours: {}, criteria: [])
end
CORS == Cross-origin resource sharing (CORS) A nice explanation can be found in this article. In summary:
CORS is an HTTP-header based security mechanism that defines who’s allowed to interact with your API. CORS is built into all modern web browsers, so in this case the “client” is a front-end of the application.
In the most simple scenario, CORS will block all requests from a different origin than your API. “Origin” in this case is the combination of protocol, domain, and port. If any of these three will be different between the front end and your Rails application, then CORS won’t allow the client to connect to the API.
So, for example, if your front end is running at https://example.com:443 and your Rails application is running at https://example.com:3000, then CORS will block the connections from the front end to the Rails API. CORS will do so even if they both run on the same server.
So the TL;DR is that we have to enable our front-end to access our back-end in 2 steps:
- Uncomment
gem "rack-cors"
in the GEMFILE, thenbundle install
- Go to
config/initializers/cors.rb
and specify from which URL (and which actions) that you are willing to accept requests
For example:
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'http://example.com:80'
resource '/orders',
:headers => :any,
:methods => [:post]
end
end
Or to just blindly allow all (only for now)
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'
resource '*', headers: :any, methods: [:get, :post, :patch, :put]
end
end
- Adding users and Pundit 👉 Le Wagon student tutorial
- Adding ActiveStorage and Cloudinary 👉 Setup instructions
- Using JBuilder for JSON views 👉 jbuilder docs / jbuilder example
- Writing tests 👉 Setup RSpec, Video part 1 and part 2
- Test Examples 👉 Controller Example / Model Example
We've "tagged" our cafes with certain criteria ie: wifi
, outlets
, coffee
etc.
Let's create an end-point for our front-end so that we can display all of these criteria.
Add in a criteria index inside our our namespaced routes.
namespace :api, defaults: { format: :json } do
namespace :v1 do
resources :cafes, only: [ :index, :create ]
resources :criteria, only: [ :index ]
end
end
Generate controller
rails g controller api/v1/criteria
We don't actually have a criteria model so we're going to pull all of the criteria from our cafe
s using the .pluck
and .flatten
methods. Then make sure we're not duplicating any using the .uniq
method:
def index
@criteria = Cafe.pluck(:criteria).flatten.uniq
render json: @criteria
end
We can test it out by visiting /api/v1/criteria
in the browser which should return a JSON array of our criteria.