Custom JSON Rendering Using Rails

Learning Goals

  • Render JSON from a Rails controller
  • Select specific model attributes to render in a Rails controller

Introduction

By using render json: in our Rails controller, we can take entire models or even collections of models, have Rails convert them to JSON, and send them out on request. We already have the makings of a basic API. In this lesson, we're going to look at shaping that data that gets converted to JSON and making it more useful to us from the frontend JavaScript perspective.

The way we structure our data matters - it can lead to better, simpler code in the future. By specifically defining what data is being sent via a Rails controller, we have full control over what data our frontend has access to.

To follow along, run rails db:migrate and rails db:seed to set up your database and example data. We will continue to use our bird watching example in this lesson.

Adding Additional Routes to Separate JSON Data

The simplest way to make data more useful to us is to provide more routes and actions that help to divide and organize our data. For instance, we could add a show action to allow us to send specific record/model instances. First, we'd add a route:

Rails.application.routes.draw do
  get '/birds' => 'birds#index'
  get '/birds/:id' => 'birds#show' # new
end

Then we could add an additional action:

class BirdsController < ApplicationController
  def index
    birds = Bird.all
    render json: birds
  end

  def show
    bird = Bird.find_by(id: params[:id])
    render json: bird
  end
end

Reminder: No need for instance variables anymore, since we're immediately rendering birds and bird to JSON and are not going to be using ERB.

Now, visiting http://localhost:3000/birds will produce an array of Bird objects, but http://localhost:3000/birds/2 will produce just one:

{
  "id": 2,
  "name": "Grackle",
  "species": "Quiscalus Quiscula",
  "created_at": "2019-05-09T21:51:41.543Z",
  "updated_at": "2019-05-09T21:51:41.543Z"
}

We can use multiple routes to differentiate between specific requests. In an API, these are typically referred to as endpoints. A user of the API uses endpoints to access specific pieces of data. Just like a normal Rails app, we can create full CRUD based controllers that only render JSON.

ASIDE: If you've ever tried using rails generate scaffold to create a resource, you'll find that this is the case. Rails has favored convention over configuration and will set up JSON rendering for you almost immediately out of the box.

In terms of communicating with JavaScript, even when sending POST requests, we do not need to change anything in our controller to handle a fetch() request compared to a normal user visiting a page. This means that you could go back to any existing Rails project and all you would need to do is change the rendering portion of the controller to make it render JSON. Bam! You have a rudimentary Rails API!

Even though we are no longer serving up views the same way, maintaining RESTful conventions is still a HUGE plus here for your API end user (mainly yourself at the moment).

Removing Content When Rendering

Sometimes, when sending JSON data, such as an entire model, we don't want or need to send the entire thing. Some data is sensitive, for instance. An API that sends user information might contain details of a user internally that it does not want to ever share externally. Sometimes, data is just extra clutter we don't need. Consider, for instance, the last piece of data:

{
  "id": 2,
  "name": "Grackle",
  "species": "Quiscalus Quiscula",
  "created_at": "2019-05-09T21:51:41.543Z",
  "updated_at": "2019-05-09T21:51:41.543Z"
}

For our bird watching purposes, we probably don't need bits of data like created_at and updated_at. Rather than send this unnecessary info when rendering, we could just pick and choose what we want to send:

def show
  bird = Bird.find_by(id: params[:id])
  render json: {id: bird.id, name: bird.name, species: bird.species } 
end

Here, we've created a new hash out of three keys, assigning the keys manually with the attributes of bird.

The result is that when we visit a specific bird's endpoint, like http://localhost:3000/birds/3, we'll see just the id, name and species:

{
  "id": "3",
  "name": "Common Starling",
  "species": "Sturnus Vulgaris"
}

Another option would be to use Ruby's built-in slice method. On the show action, that would look like this:

def show
  bird = Bird.find_by(id: params[:id])
  render json: bird.slice(:id, :name, :species)
end

This achieves the same result but in a slightly different way. Rather than having to spell out each key, the Hash slice method returns a new hash with only the keys that are passed into slice. In this case, :id, :name, and :species were passed in, so created_at and updated_at get left out, just like before.

{
  "id": "3",
  "name": "Common Starling",
  "species": "Sturnus Vulgaris"
}

Cool, but once again, Rails has one better. While slice works fine for a single hash, as with bird, it won't work for an array of hashes like the one we have in our index action:

def index
  birds = Bird.all
  render json: birds
end

In this case, we can add in the only: option directly after listing an object we want to render to JSON:

def index
  birds = Bird.all
  render json: birds, only: [:id, :name, :species]
end

Visiting or fetching http://localhost:3000/birds will now produce our array of bird objects and each object will only have the id, name and species values, leaving out everything else:

[
  {
    "id": 1,
    "name": "Black-Capped Chickadee",
    "species": "Poecile Atricapillus"
  },
  {
    "id": 2,
    "name": "Grackle",
    "species": "Quiscalus Quiscula"
  },
  {
    "id": 3,
    "name": "Common Starling",
    "species": "Sturnus Vulgaris"
  },
  {
    "id": 4,
    "name": "Mourning Dove",
    "species": "Zenaida Macroura"
  }
]

Alternatively, rather than specifically listing every key we want to include, we could also exclude particular content using the except: option, like so:

def index
  birds = Bird.all
  render json: birds, except: [:created_at, :updated_at]
end

The above code would achieve the same result, producing only id, name, and species for each bird. All the keys except created_at and updated_at.

Drawing Back the Curtain on Rendering JSON Data

As we touched upon briefly in the previous lesson, the controller actions we have seen so far have a bit of Rails 'magic' in them that obscures what is actually happening in the render statements. The only and except keywords are actually parameters of the to_json method, obscured by that magic. The last code snippet can be rewritten in full to show what is actually happening:

def index
  birds = Bird.all
  render json: birds.to_json(except: [:created_at, :updated_at])
end

As customization becomes more complicated, writing in sometimes help to clarify what is happening.

Basic Error Messaging When Rendering JSON Data

With the power to create our own APIs, we also have the power to define what to do when things go wrong. In our show action, we are currently using Bird.find_by, passing in id: params[:id]:

def show
  bird = Bird.find_by(id: params[:id])
  render json: {id: bird.id, name: bird.name, species: bird.species } 
end

When using find_by, if the record is not found, nil is returned. As we have it set up, if params[:id] does not match a valid id, nil will be assigned to the bird variable.

As nil is a false-y value in Ruby, this gives us the ability to write our own error messaging in the event that a request is made for a record that doesn't exist:

def show
  bird = Bird.find_by(id: params[:id])
  if bird
    render json: { id: bird.id, name: bird.name, species: bird.species }
  else
    render json: { message: 'Bird not found' }
  end
end

Now, if we were to send a request to an invalid endpoint like http://localhost:3000/birds/hello_birds, rather than receiving a general HTTP error, we would still receive a response from the API:

{
  "message": "Bird not found"
}

From here, we could build a more complex response, including additional details about what might have occurred.

Conclusion

We can now take a single model or all the instances of that model and render it to JSON, extracting out any specific content we do or do not want to send!

Whether you are building a professional API for a company or for your own personal site, having the ability to fine tune how your data look is a critical skill that we're only just beginning to scratch the surface on.

In the next lesson, we're going to continue to look at options for customizing rendered JSON content. Particularly, we'll be looking more at what we can add.