0.1 Check which version of Ruby is installed:
ruby -v
ruby 2.6.3p62 (2019-04-16 revision 67580)
Better yet, use which because it gives us the full path of our Ruby interpreter (and indicates whether we’re using rvm):
which ruby
~/.rvm/rubies/ruby-2.6.3/bin/ruby
0.2 What version of Rails is installed?
rails -v
Rails 5.2.3
Or:
which rails
~/.rvm/gems/ruby-2.6.3/bin/rails
0.3 Is Postgres running?
If you have the Postgres OS X app installed, simply check the menu-bar application to check.
There are multiple ways to check the status of postgres from the command line, including pg-ctl but we won’t cover that now.
0.4 Is yarn installed?
Because we’re using the webpacker gem, we’ll need yarn (the Javascript dependency manager).
brew install yarn
Then install the Javascript dependencies:
yarn install
Due to reasons, you may also need to manually install some of the Javascript dependencies:
yarn add bootstrap jquery popper.jsUse rails new to create a new Ruby on Rails application.
rails new \
--webpack \
--database postgresql \
-m https://raw.githubusercontent.com/lewagon/rails-templates/master/devise.rb \
restaurants-api
cd into our new application and install the pundit gem. Open Gemfile and add:
gem 'pundit'Then run:
bundle install
Finally, use rails generate to setup Pundit:
rails generate pundit:install
For this json API application, let’s keep the model simple: Restaurant and Comment. Let’s use rails generate to do create them:
rails g model restaurant name address user:references
rails g model comment content:text restaurant:references user:references
Restaurant references User, and Comment references Restaurant and User.
Now, let’s migrate the database:
rake db:migrate
Don’t forget to add has_many :restaurants to User and has_many :comments to Restaurant.
Pundit is a gem that handles User authentication policy, which makes controlling access to our various API endpoints a bit easier.
Let’s use the Pundit rails generate plugin:
rails generate pundit:policy restaurant
Running via Spring preloader in process 3739
create app/policies/restaurant_policy.rb
invoke test_unit
create test/policies/restaurant_policy_test.rb
We’ll come back to the Pundit policy a bit later.
Our API will have the following endpoints:
GET /api/v1/restaurants (unauthenticated)GET /api/v1/restaurants/:id (unauthenticated)PATCH /api/v1/restaurants/:id (authenticated)POST /api/v1/restaurants (authenticated)DELETE /api/v1/restaurants/:id (authenticated)
Though these endpoints differ in the particulars, for example some can be accessed without authentication (while others not), in fact they have a lot in common. All our endpoints must work with authentication and errors in the same way, therefore we will create a BaseController that implements all this common functionality.
First, let’s create the BaseController. Instead of using rails generate we’ll create the files manually:
mkdir -p app/controllers/api/v1
touch app/controllers/api/v1/base_controller.rb
Open app/controllers/api/v1/base_controller.rb and add:
class Api::V1::BaseController < ActionController::API
include Pundit
after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :index
rescue_from StandardError, with: :internal_server_error
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
rescue_from ActiveRecord::RecordNotFound, with: :not_found
private
def user_not_authorized(exception)
render json: {
error: "Unauthorized #{exception.policy.class.to_s.underscore.camelize}.#{exception.query}"
}, status: :unauthorized
end
def not_found(exception)
render json: { error: exception.message }, status: :not_found
end
def internal_server_error(exception)
if Rails.env.development?
response = { type: exception.class.to_s, message: exception.message, backtrace: exception.backtrace }
else
response = { error: "Internal Server Error" }
end
render json: response, status: :internal_server_error
end
endThere’s a fair bit going on in here. Let’s start at the top:
include PunditHere we’re including Pundit so we can use its authentication features.
after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :indexWhen you are developing an application with Pundit it can be easy to forget to authorise some action. People are forgetful after all. Since Pundit encourages you to add the authorise call manually to each controller action, it's really easy to miss one.
Thankfully, Pundit has a handy feature which reminds you in case you forget. Pundit tracks whether you have called authorise anywhere in your controller action. Pundit also adds a method to your controllers called verify_authorized. This method will raise an exception if authorise has not yet been called.
rescue_from StandardError, with: :internal_server_error
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
rescue_from ActiveRecord::RecordNotFound, with: :not_foundTo make our API as opaque as possible, we must be careful not to tip our hand with verbose error messages that might give clues away about how it works under the hood.
We can use rescue_from to catch various error types (e.g. StandardError, Pundit::NotAuthorizedError and ActiveRecord::RecordNotFound) and respond with some simple JSON responses.
Api::V1::BaseController#user_not_authorized is invoked when our controller catches a Pundit::NotAuthorizedError. It returns a little JSON blob with a few fields detailing the error:
private
def user_not_authorized(exception)
render json: {
error: "Unauthorized #{exception.policy.class.to_s.underscore.camelize}.#{exception.query}"
},
status: :unauthorized
endApi::V1::BaseController#not_found is invoked when our controller catches an ActiveRecord::RecordNotFound. It, too, returns a little JSON blob:
def not_found(exception)
render json: { error: exception.message }, status: :not_found
endApi::V1::BaseController#internal_server_error is invoked when our controller catches a StandardError which is a generic catchall error. It, too, returns a little JSON blob, but the content depends on whether our application is running in the development environment or not. If it’s running in development, it’s safe to give the user verbose error messages. Otherwise, we’ll just say “Internal Server Error”.
def internal_server_error(exception)
if Rails.env.development?
response = { type: exception.class.to_s, message: exception.message, backtrace: exception.backtrace }
else
response = { error: "Internal Server Error" }
end
render json: response, status: :internal_server_error
end
The first API endpoint we’ll implement is GET /api/v1/restaurants which will return an array of Restaurant models in an application/json response.
To begin, let’s create a controller called Api::V1::RestaurantsController to handle the request. Again, we’ll manually create these instead of using rails generate:
touch app/controllers/api/v1/restaurants_controller.rb
mkdir -p app/views/api/v1/restaurants
Open the new controller and add:
class Api::V1::RestaurantsController < Api::V1::BaseController
def index
@restaurants = policy_scope(Restaurant)
end
endThe first thing to note is that RestaurantController should inherit from Api::V1::BaseController so that it gets all of the functionality we implemented around authentication and error handling!
Next, we make Api::V1::RestaurantsController#index load a list of restaurants, limited to just those the current_user is permitted to view:
def index
@restaurants = policy_scope(Restaurant)
endNote that our controller isn’t explicitly rendering anything. It is still expecting to load the @restaurants variable into a view at the default location (i.e. app/views/api/v1/restaurants/index.<whatever>). We’ll create this later.
Now, let’s plug GET /api/v1/restaurants into our controller. Open config/routes.rb and add:
namespace :api, defaults: { format: :json } do
namespace :v1 do
resources :restaurants, only: [ :index ]
end
endThis creates the namespace /api/vi/ and then plugs /restaurants into RestaurantsController#index.
Finally, let’s create a view to render our data. Create app/views/api/v1/restaurants/index.json.jbuilder:
touch app/views/api/v1/restaurants/index.json.jbuilder
And add:
json.array! @restaurants do |restaurant|
json.extract! restaurant, :id, :name, :address
endThis will return an array of Restaurant with just the id, name and address fields. We are purposefully excluding a lot of data from Restaurant including for example :created_at etc. (data which the User shouldn’t really be too concerned with.
With our server running, send a live request to the endpoint:
curl -v -s http://localhost:3000/api/v1/restaurants
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 3000 (#0)
> GET /api/v1/restaurants HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< ETag: W/"4f53cda18c2baa0c0354bb5f9a3ecbe5"
< Cache-Control: max-age=0, private, must-revalidate
< X-Request-Id: 82407482-5f3f-402b-83bf-da9a5d521e62
< X-Runtime: 0.009189
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
[]
If we’d seeded the application with some data (db/seeds.rb) then we’d get something other than [] in response. But look! The content-type is application/json so that’s awesome.
The second API endpoint we’ll implement is GET /api/v1/restaurants/:id which will return an object representing a single Restaurant model, along with any associated Comments, wrapped up in a nice application/json response.
To begin, let’s implement #show in Api::V1::RestaurantsController to handle the request.
Open application/controllers/restaurants_controller.rb and add:
class Api::V1::RestaurantsController < Api::V1::BaseController
before_action :set_restaurant, only: [ :show ]
def index
@restaurants = policy_scope(Restaurant)
end
def show
end
private
def set_restaurant
@restaurant = Restaurant.find(params[:id])
authorize @restaurant # For Pundit
end
endInstead of loading the Restaurant with policy_scope (as we do in #index), we create a private method called set_restaurant which executes before #show is invoked. I literally don’t understand the advantage of doing it that way, but apparently it’s part of how Pundit works. So there.
Two more steps! Remember how #show isn’t explicitly rendering anything? It’s still expecting to load the @restaurant variable into a view at the default location (i.e. app/views/api/v1/restaurants/show.<whatever> ). We’ll create this later.
Now, let’s plug GET /api/v1/restaurants/:id into our controller. Open config/routes.rb and alter the resources :restaurants line to show:
resources :restaurants, only: [ :index, :show ]This plugs /restaurants/:id into RestaurantsController#show.
Finally, let’s create a view to render our data. Create app/views/api/v1/restaurants/show.json.jbuilder:
touch app/views/api/v1/restaurants/show.json.jbuilder
And add:
json.extract! @restaurant, :id, :name, :address
json.comments @restaurant.comments do |comment|
json.extract! comment, :id, :content
endThis will return a Restaurant with just the id, name and address fields, along with any Comments associated it.
With our server running, send a live request to the endpoint:
curl -v -s http://localhost:3000/api/v1/restaurants/1
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 3000 (#0)
> GET /api/v1/restaurants/1 HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 404 Not Found
< Content-Type: application/json; charset=utf-8
< Cache-Control: no-cache
< X-Request-Id: b4f7fdc0-54a1-474e-8de9-a3ae48ac916a
< X-Runtime: 0.004788
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
{"error":"Couldn't find Restaurant with 'id'=1"}
So…if we’d seeded the application with some data (db/seeds.rb) then wouldn’t get {"error":"Couldn't find Restaurant with 'id'=1"}.
But hey, It does tell is that the error handler we defined in Api::V1::BaseController is doing its job!
Let’s take a quick detour to seed some data. Open db/seeds.rb and add:
puts 'Cleaning database...'
Comment.destroy_all
Restaurant.destroy_all
User.destroy_all
puts 'Creating user...'
user = User.create! :email => '[email protected]', :password => 'api_user', :password_confirmation => 'api_user'
puts 'Creating restaurant...'
restaurant = Restaurant.create! :name => 'rexmortus', :address => '100 Bourke Street, Melbourne', :user => user
puts 'Creating comments...'
Comment.create! :restaurant => restaurant, :user => user, :content => 'Example Comment 1'
Comment.create! :restaurant => restaurant, :user => user, :content => 'Example Comment 2'
Comment.create! :restaurant => restaurant, :user => user, :content => 'Example Comment 3'
puts 'Finished!'Now run:
rake db:seed
Cleaning database...
Creating user...
Creating restaurant...
Creating comments...
Finished!
Boom. Now, when we hit GET /api/v1/restaurants/1 we should get some sweet, juicy data. BUT WHAT’S THIS?
curl -s -v http://localhost:3000/api/v1/restaurants/1
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 3000 (#0)
> GET /api/v1/restaurants/1 HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
< Content-Type: application/json; charset=utf-8
< Cache-Control: no-cache
< X-Request-Id: 139578f8-8a7e-4bb2-8926-50aa30f586bf
< X-Runtime: 0.107412
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
{"error":"Unauthorized RestaurantPolicy.show?"}
Blast! An error. Open /app/policies/application_policy.rb, and see howshow? returns false? We have to override this in RestaurantPolicy. Open /app/policies/restaurant_policy.rb and add:
class RestaurantPolicy < ApplicationPolicy
class Scope < Scope
def resolve
scope.all
end
end
def show?
true
end
endI’m honestly not sure why Scope isn’t catching this particular invocation but hey, there you go.
Now let’s send another request to our endpoint, and fingers crossed, we’ll get some delicious data:
curl -s -v http://localhost:3000/api/v1/restaurants/1
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 3000 (#0)
> GET /api/v1/restaurants/1 HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< ETag: W/"1f473e861e9ab440410be4f6a45ed050"
< Cache-Control: max-age=0, private, must-revalidate
< X-Request-Id: ff4e2a98-00bb-49d1-908f-fc2f771b82db
< X-Runtime: 0.009095
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
{"id":1,"name":"rexmortus","address":"100 Bourke Street, Melbourne","comments":[{"id":1,"content":"Example Comment 1"},{"id":2,"content":"Example Comment 2"},{"id":3,"content":"Example Comment 3"}]}
Woo! It works.
The third API endpoint we will implement is PATCH /api/v1/restaurants/:id which updates a single existing Restaurant model, and then returns then it in a application/json response.
Now, PATCH functionality has a special requirement: only authenticated users should be able to access it.
Because this is an API, we need to implement a way for Users of our API to authenticate themselves without using the sign-in form that ships as part of Devise. We need to issue API tokens!
Fortunately, this is a common requirement and there are many ready-made solutions. For this example we’ll use simple_token_authentication, a particularly straightforward solution. Open your Gemfile and add:
gem 'simple_token_authentication'Then, install it:
bundle install
...
Fetching simple_token_authentication 1.15.1
Installing simple_token_authentication 1.15.1
...
Bundle complete! 21 Gemfile dependencies, 78 gems now installed.
Next, let’s use rails generate to generate a migration that adds token to the User model, which we’ll use later as the main mechanism for authenticating Users and restricting access to parts of our API.
rails generate migration AddTokenToUsers "authentication_token:string{30}:uniq"
Running via Spring preloader in process 28098
invoke active_record
create db/migrate/20190528105531_add_token_to_users.rb
To begin, lets implement #update in Api::V1::RestaurantsController to handle the request.
Finally, let’s run the new migration:
rake db:migrate
== 20190528105531 AddTokenToUsers: migrating ==================================
-- add_column(:users, :authentication_token, :string, {:limit=>30})
-> 0.0023s
-- add_index(:users, :authentication_token, {:unique=>true})
-> 0.0097s
== 20190528105531 AddTokenToUsers: migrated (0.0123s) =========================
Now that our database has been migrated, let’s enable the token feature on the User model. Open app/models/user.rb and add:
class User < ApplicationRecord
acts_as_token_authenticatable
# [...]
endNow simple_token_authentication knows where to look for the actual token, (i.e. the model User) !
Next up, we have to configure our controller to use simple_token_authentication as an authentication strategy. Open api/v1/restaurants_controller.rb and add:
class Api::V1::RestaurantsController < Api::V1::BaseController
acts_as_token_authentication_handler_for User, except: [ :index, :show ]
before_action :set_restaurant, only: [ :show, :update ]
def index
@restaurants = policy_scope(Restaurant)
end
def show
end
def update
if @restaurant.update(restaurant_params)
render :show
else
render_error
end
end
private
def restaurant_params
params.require(:restaurant).permit(:name, :address)
end
def render_error
render json: { errors: @restaurant.errors.full_messages },
status: :unprocessable_entity
end
def set_restaurant
@restaurant = Restaurant.find(params[:id])
authorize @restaurant # For Pundit
end
endThere are a few things going on here:
-
We’re adding:
acts_as_token_authentication_handler_for User, except: [ :index, :show ]
Which applies our new
tokenauthentication strategy to#update, but not#indexand#show.-
We’re setting
@restaurantin#update:before_action :set_restaurant, only: [ :show, :update ]
-
Also we’re defining
#update:def update if @restaurant.update(restaurant_params) render :show else render_error end end
This updates a
Restaurant(with the id specified as:idin/api/v1/restaurants/:id) with theform-datayielded from#restaurant_params(which we’ll discuss in a moment.@restaurant.updatereturns abooleanvalue, so if the#updateis successful, we’ll render the template for#show, otherwise we’ll render a simple error for the user. -
We’re also defining
#restaurant_paramswhich follows the Rails convention of whitelisting just the parameters we want for the action:def restaurant_params params.require(:restaurant).permit(:name, :address) end
-
Now that RestaurantsController implements update, let’s plug it into our router. Open /config/routes.rb and add :update:
resources :restaurants, only: [ :index, :show, :update ]Ok, one last thing… We’ve configured the User model to use our token for authentication, but the User we seeded earlier won’t have one! We have two options:
-
Re-seed the database
rake db:seed
-
Using the
rails console, re-save the user (which creates atoken):user = User.find_by(email: "[email protected]") user.save # The user did not have any token yet. This call generated one. user.reload.authentication_token # => "a6hYpzsfNJdYC6zEMxs3"
Now, we are finally ready to send a request:
curl -i -v -X PATCH \
-H 'Content-Type: application/json' \
-H 'X-User-Email: [email protected]' \
-H 'X-User-Token: RG7MWnncjsaUvdEhRemL' \
-d '{ "restaurant": { "name": "New name" } }' \
http://localhost:3000/api/v1/restaurants/2
...
{"error":"Unauthorized RestaurantPolicy.update?"}
Gotcha! Still have to update our RestaurantPolicy to allow this action. Open config/policies/restaurant_policy.rb and add:
def update?
true
endOk, NOW we can actually do the request:
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 3000 (#0)
> PATCH /api/v1/restaurants/2 HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Type: application/json
> X-User-Email: [email protected]
> X-User-Token: RG7MWnncjsaUvdEhRemL
> Content-Length: 40
>
* upload completely sent off: 40 out of 40 bytes
< HTTP/1.1 200 OK
HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
Content-Type: application/json; charset=utf-8
< ETag: W/"b3c82c9d643e3aca9f6ce84cfcc928c5"
ETag: W/"b3c82c9d643e3aca9f6ce84cfcc928c5"
< Cache-Control: max-age=0, private, must-revalidate
Cache-Control: max-age=0, private, must-revalidate
< X-Request-Id: e131bab9-c18d-4bc9-a6fe-d941f777d911
X-Request-Id: e131bab9-c18d-4bc9-a6fe-d941f777d911
< X-Runtime: 0.015231
X-Runtime: 0.015231
< Transfer-Encoding: chunked
Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
{"id":2,"name":"New name","address":"100 Bourke Street, Melbourne","comments":[{"id":4,"content":"Example Comment 1"},{"id":5,"content":"Example Comment 2"},{"id":6,"content":"Example Comment 3"}]}Charitys-MacBook-Pro:restaurants-api
Take a moment to study the headers. Evidence of our work is everywhere!
Now let’s implement the 4th endpoint: POST /api/v1/restaurants, which creates a new Restaurant model from the POST parameters.
We should restrict unauthenticated users from using this action, so that only requests with a valid token can create new Restaurant instances.
Lucky for us, we’ve already configured Pudnit and simple_token_authentication, so this implementation is very straightforward.
First, let’s start by implementing #create on API::V1::RestaurantsController to handle this request. Open app/controllers/restaurants_controller.rb and add:
def create
@restaurant = Restaurant.new(restaurant_params)
@restaurant.user = current_user
authorize @restaurant
if @restaurant.save
render :show, status: :created
else
render_error
end
endFirst, we call Restaurant#new and pass it the parameters yielded from restaurant_params (which we previously implemented for #update).
Then, we set the Restaurant User to current_user, which should yield whatever User is associated with the token we submit with the request.
Next, we use Pundit to apply our access policy (i.e.authorize @restaurant).
A note: this action will be disallowed until we update RestaurantPolicy. Open config/policies/restaurant_policy.rb and add:
def create?
true
endFinally, we invoke restaurant#save which will attempt to write the new data. Remember, #save returns a BOOL so there are two possible outcomes:
-
If
#savereturnstruethen we’ll execute:render :show, status: :created
That renders the
#showtemplate, additionally passing in a variable calledstatus.- If
#savereturnsfalsethen we’ll invokerender_error, which was described earlier in this example.
- If
The final step is to plug POST /api/v1/restaurants into RestaurantsController#create. Open config/routes.rb and add :create:
resources :restaurants, only: [ :index, :show, :update, :create ]Ok! We are ready to create a new Restaurant via. our new route handler:
curl -i -v POST \
-H 'Content-Type: application/json' \
-H 'X-User-Email: [email protected]' \
-H 'X-User-Token: RG7MWnncjsaUvdEhRemL' \
-d '{ "restaurant": { "name": "New restaurant", "address": "Paris" } }' \
http://localhost:3000/api/v1/restaurants
* Connected to localhost (::1) port 3000 (#1)
> POST /api/v1/restaurants HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Type: application/json
> X-User-Email: [email protected]
> X-User-Token: RG7MWnncjsaUvdEhRemL
> Content-Length: 66
>
* upload completely sent off: 66 out of 66 bytes
< HTTP/1.1 201 Created
HTTP/1.1 201 Created
< Content-Type: application/json; charset=utf-8
Content-Type: application/json; charset=utf-8
< ETag: W/"792cd735c8f59e4921f916e00f7bd363"
ETag: W/"792cd735c8f59e4921f916e00f7bd363"
< Cache-Control: max-age=0, private, must-revalidate
Cache-Control: max-age=0, private, must-revalidate
< X-Request-Id: 1f28cdd7-7c00-4911-a707-74f9c2dbd6ce
X-Request-Id: 1f28cdd7-7c00-4911-a707-74f9c2dbd6ce
< X-Runtime: 0.067653
X-Runtime: 0.067653
< Transfer-Encoding: chunked
Transfer-Encoding: chunked
<
* Connection #1 to host localhost left intact
{"id":5,"name":"New restaurant","address":"Paris","comments":[]}
Shaboom shaboom!
Now let’s implement the 5th and final endpoint: DELETE /api/v1/restaurants/:id, which deletes a Restaurant model with the id yielded by /api/v1/restaurants/:id.
We should restrict unauthenticated users from using this action, so that only requests with a valid token can delete Restaurant instances.
Let’s start by implementing #delete on API::V1::RestaurantsController to handle this request. Open app/controllers/restaurants_controller.rb and add:
before_action :set_restaurant, only: [ :show, :destroy ]
def destroy
@restaurant.destroy
head :no_content
endTwo things are happening here. The first is that we’re configuring destroy to execute #set_restaurant before it’s invoked:
before_action :set_restaurant, only: [ :show, :update, :destroy ]That way @restaurant is defined just prior invoking #destroy on it:
def destroy
@restaurant.destroy
head :no_content
endAgain, let’s update our RestaurantPolicy to allow this action. Open config/policies/restaurant_policy.rb and add:
def destroy?
true
endNow that our API::V1::RestaurantsController is configured to handle #destroy, let’s plug DELETE /api/v1/restaurants/:id into it. Open config/routes.rb and add:
resources :restaurants, only: [ :index, :show, :update, :create, :destroy ]At long last, let’s DELETE one of these suckers:
curl -i -X DELETE
-H 'X-User-Email: [email protected]'
-H 'X-User-Token: RG7MWnncjsaUvdEhRemL'
http://localhost:3000/api/v1/restaurants/3
HTTP/1.1 204 No Content
Cache-Control: no-cache
X-Request-Id: 8ecd6ffe-bd38-4403-8980-e09544fced77
X-Runtime: 0.012054
Note: if a Restaurant has associated Comments, attempting to delete it will cause a “foreign key” error. So, like, just don’t have any comments for now!