
Crafting A RESTful Ruby on Rails API
APIs have become a major cornerstone of Internet systems, as the interconnectivity of web applications, brick-and-mortar businesses, and services have dramatically increased over the last decade. API's can bolster a business's relevance and long-term survival and can often be a new source of income. But to be successful, you need a well-designed API that is designed for optimum return on investment. A poorly designed API can lead to costly support calls from API customers as they inquire about help integrating, or complain about API changes that you made, breaking their own costly integrations--they often will hire developers to do a custom integration--thwarting their ability to access data. To optimize your API, its needs to be RESTful and, in this guide, we'll use Ruby on Rails, which was originally designed for API usage from the start.
What is a RESTful API?
A RESTful API is an application program interface (API) that accepts HTTP GET, PUT, POST and DELETE data requests. REST is acronym for REpresentational State Transfer. The general architectural design of REST is:
- Uniformality: The design is uniform throughout. Each resource should be individually accessible, with no resource object too large, or if not better suited to be accessed by separate calls. Getting resources should have common approaches such that all resources are accessed and updated similarly.
- Cient-Server: Accessing an API should not have dependencies--the access should be simple with the ability to make changes on either the client or server, but that the bridge between them remains consistent and non-brittle.
- Stateless: There is no state stored on the server between requests, without use of sessions or cookies. Each request is considered individual with no connection to any prior or future request;
- Unique resources per request: Each request should uniquely identify a single resource, and can list or update zero or more resource objects;
- Representational state transfer: The API should return a representation of resource date, typically in JSON or XML.
Rest Architectural Constraints has a good explanation of REST.
Setup: Example Blog Application
Create a New Application
To get started, we will create a blog application with resources that will be available via API. While it is possible to offer JSON responses alongside default HTML responses, we will create a subset of versioned controllers that are exclusive to the API, which provides a clear distinction within the code and is simpler to work with in the long term. We will use the Full Rails App instructions below, which you can add on additional views that might be available outside of the API, but you can also use the API Only instructions if that is your focus.
# Standard Ruby on Rails application
$ rails new blog
$ cd blog
$ bundle
# API-only Ruby on Rails application
$ rails new blog --api
$ cd blog
$ bundle
The API-only instructions will:
Configure your application to start with a more limited set of middleware than normal, exlcuding any middleware primarily useful for browser applications (like sessions and cookies support) by default.
Make ApplicationController inherit from ActionController::API instead of ActionController::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.
One downside to using the API-only approach is that if you want to add an admin interface or views outside of an API, you'll have to manually add back and modify your application setup for this to work, a real pain.
Controllers
Create the directory for our API controllers:
$ mkdir -p app/controllers/api/v1
Base controller with Authentication, Error handling
Create a Base Controller that all of our API controllers will inherit from, and we'll add behaviors that we want all of our API controllers to utilize. In this controller, which will apply to ALL API requests, we'll:
- Check that requests need to be in JSON;
- Check that requests are authenticated;
- Catch all 'Not Found' requests to the API and render an error message;
- Have a common method
render_errors
for rendering errors
# app/controller/api/v1/base_controller.rb
class Api::V1::BaseController < ApplicationController
before_action :ensure_json_request
before_action :verify_token!
skip_before_action :verify_authenticity_token
def render_not_found
render_errors([{ code: '5', message: 'Resource not found' }], :not_found)
end
private
def ensure_json_request
return if request.format == :json
request.format.symbol == :json
render_errors([{ code: '1', message: 'Requests need to be in JSON format.'}], :not_acceptable)#406
end
def verify_token!
unless params[:token] == 'secretkey'
render_errors([{ code: '2', message: 'Unauthorized'}], :unauthorized)#401
end
end
def render_errors(errors=[], status=:ok)
render json: { errors: errors }, status: status
end
end
API Resources
Post Model
Create the Post model and migrate it to the database:
$ rails g model Post title content:text publication_date:date author
$ rails db:migrate
Post Controller
Create our Posts controller:
# posts_controller.rb
class Api::V1::PostsController < Api::V1::BaseController
before_action :get_post, only: [:show, :update, :destroy]
def index
posts = Post.all
render json: { posts: posts }, status: :success
end
def show
render json: @post, status: :success
end
def update
if @post.update(post_params)
render json: @post, status: :ok
else
render_errors(@post.errors, :unprocessable_entity)
end
end
def create
post = Post.new(post_params)
if post.save
render json: post, status: :created
else
render_errors(@post.errors, :unprocessable_entity)
end
end
def destroy
if @post && @post.destroy
render json: { message: 'Post successfully destroyed' }, status: :no_content
else @post
render_errors(@post.errors, :unprocessable_entity)
end
end
private
def post_params
params.require(:post).permit(:title, :content, :publication_date, :author)
end
def get_post
@post = Post.find_by(id: params[:id])
unless @post
render_errors([{code: '3', message: 'Post not found'}], :not_found)
end
end
end
Routes
# routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :posts
get '*path', to: 'base#render_not_found'
end
end
end
With the routes in place, we can see that our routes exist:
$ rake routes
Prefix Verb URI Pattern Controller#Action
api_v1_posts GET /api/v1/posts(.:format) api/v1/posts#index
POST /api/v1/posts(.:format) api/v1/posts#create
new_api_v1_post GET /api/v1/posts/new(.:format) api/v1/posts#new
edit_api_v1_post GET /api/v1/posts/:id/edit(.:format) api/v1/posts#edit
api_v1_post GET /api/v1/posts/:id(.:format) api/v1/posts#show
PATCH /api/v1/posts/:id(.:format) api/v1/posts#update
PUT /api/v1/posts/:id(.:format) api/v1/posts#update
DELETE /api/v1/posts/:id(.:format) api/v1/posts#destroy
Using the API
Start the server up:
$ rails server
Authentication
The authentication in our application is basic token authentication, requiring a secret token ('secretkey') that must be passed in the params on every request.
http://localhost:3000/api/v1/posts.json?token=secretkey
We check for the token in BaseController
. Without the token
, you will get a 401 Unauthorized
HTTP response:
{"errors":[{"code":"2","message":"Unauthorized"}]}
Create a Post
To create a post, we will submit a HTTP POST with the params of a post object, and include the necessary authentication token.
Request
POST http://localhost:3000/api/v2/posts.json?token=secretkey
With a body type of application/json
and content:
{
"post": {
"title": "Title of a Blog post",
"content": "This is the content",
"author": "John Doe"
}
}
Response
This will get a 201 Created
response and content:
{
"id": 1,
"title": "Title of a Blog post",
"content": "This is the content",
"publication_date": null,
"author": "John Doe",
"created_at": "2019-01-15T16:25:15.557Z",
"updated_at": "2019-01-15T16:25:15.557Z"
}
List Posts
And then we can try it out in the browser with a simple GET of posts:
Request
GET http://localhost:3000/api/v1/posts.json?token=secretkey
This will result in the following response with the one post we created:
Response
{
"posts": [
{
"id": 1,
"title": "Title of a Blog post",
"content": "This is the content",
"publication_date": null,
"author": "John Doe",
"created_at": "2019-01-15T16:25:15.557Z",
"updated_at": "2019-01-15T16:25:15.557Z"
}
],
}
Update a Post
If we want to update our post, we can:
Request
PATCH http://localhost:3000/api/v1/posts/1.json?token=secretkey
With body content:
{
"post": {
"title": "Exciting New Title"
}
}
Response
We will get a 200 OK
response and an updated post:
{
"id": 1,
"title": "Exciting New Title",
"content": "This is the content",
"publication_date": null,
"author": "John Doe",
"created_at": "2019-01-15T16:25:15.557Z",
"updated_at": "2019-01-15T16:29:53.563Z"
}
Deleting a Post
To delete our post, we:
Request
DELETE http://localhost:3000/api/v1/posts/1.json?token=secretkey
Response
This will delete the post and return a 204 No Content
.
Handling Errors
Providing insightful error messages is important in API design as developers design integrations and looks for helpful clues when they are troubleshooting an API. At a minimum, responses should contain an accurate HTTP response code, ie. a 201 created
code for a successfully created object, and a 422 unprocessable entity
code if on creation an object is invalid.
In addition to informative response codes, you can provide detailed Ruby on Rails validation errors, messages and or a custom error codes to provide even more information. REST design doesn't dictate response bodies, so take an approach and be consistent across the API. In our approach, we provide an errors
array object, to hold any number of errors, with a code
that could point to a dictionary of custom error codes that could provide additional information.
Catchall Error for Not Found Requests
We also need to capture routes that don't exist within our API. We do this with a catchall route in routes.rb:
get '*path', to: 'base#render_not_found'
Normally, you would get the standard HTML not found Rails response, which would be lots of HTML, so by doing this, we are returning a JSON response with a clear and uncluttered 'not found' message.
Error Codes
Error codes are really useful for providing more specific info and when provided to Support, they could substantially shorten the time to narrow down and identify a problem. In our sample application, we'll provide these codes:
CODE DESCRIPTION
1 Not acceptable. Request needs to be in JSON.
2 Unauthorized. Incorrect or missing correct Token.
3 Not found. This Post does not exist.
4 Not found. Resource does not exist.
We importantly, keep this errors object consistent across the API, so that any consuming app could program for it and utilize it effectively.
Modifying Response Objects
Often you'll want to exclude certain attributes in an object or add additional related objects. With our Posts for example, if we don't want to send the updated_at
field and we want to include the is_published?
method with the object response, we can modify posts_controller accordingly:
# posts_controller.rb
def show
render json: @post.as_json(except: [:updated_at], methods: [:is_published?]), status: :success #200
end
When requesting the post, it will now exclude the updated_at
attribute and it will include the is_published?
method.
Bottom line: Keep it simple, and don't change it!
Here are key tenants for overall API design:
Provide complete access (all of the same access they may have through a UI) to all of the necessary resources a user may want;
Interface should be consistent and well-named so that a user can extrapolate calls to all resources once they know how to access one resource;
Never change existing API endpoints, which could break customer integrations without substantial advance notice. Changes can be supplemental in nature, allowing for additional attributes, such that the risk of integration breakage is low, and the customer can optionally decide to utilize the new data. Never remove an endpoint unless absolutely necessary, or if you have given customers ample time to adjust for it.
Carson R Cole