Rails API for Triple Nested Resources

08/21/2020

This post provides one way to handle triple nested resources with a Rails API using the Fast JSON API gem. It also touches on how to arrange data for HTTP POST requests from a JavaScript frontend. It is based on my experience building the Trip Planner app.

Below is a screenshot of the app that demonstrates a triple nested relationship among the three resources: Trip, Day, and Place. At this moment, before a user saves data through a POST request to the backend, the DOM includes the following elements:

Visual of Triple Nested RelationshipVisual of Triple Nested Relationship

  • One trip (represented by the red box) with a map centered on the trip’s destination city;
  • Several days (green boxes) representing the daily planners; and
  • Each daily planner (green box) includes several places (blue boxes).

This can be translated into the following Active Record associations:

  • A Trip has many Days; A Trip has many Places through Days
  • A Day belongs to a Trip; A Day has many Places
  • A Place belongs to a Day

With the structure laid out, I just needed to ensure all of the following aspects are covered for a full-stack implementation.

Backend

Rails API Setup

Create the Rails backend with the api flag, which leads to some Rails default features and middleware being removed. They are mostly related to the browser. This also ensures that controllers will inherit from ActionController::API rather than ActionController::Base and generators will skip generating views.

rails new trip-planner-backend --api --database=postgresql

In the Gemfile, uncomment gem 'rack-cors' and add gem 'fast_jsonapi' before bundle or bundle install. Then, uncomment the following code:

# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*' # for development only, change to your origin in production
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head]
end
end

Rails Resource Generators and Migrations

The resource generators below will create the the corresponding migrations, models and controllers. They will not create views.

rails g resource trip city lat:decimal lng:decimal
rails g resource day date:date trip:references
rails g resource place name place_id category day:references

The following points are worth some explanations:

  • You can use “date” as the name of an attribute
  • You can’t use “type” as the name of an attribute; a good alternative is “category”
  • The “place_id” attribute for the place resource is of the string data type that saves the Google Map API’s Place IDs; it is different from the primary key, id, automatically generated by the db

Rails Models

The associations among the three resources should be reflected in the models. To be prepared for nested params, the models should also include accepts_nested_attributes_for, as follows:

# app/models/trip.rb
class Trip < ApplicationRecord
has_many :days, dependent: :destroy
has_many :places, through: :days
accepts_nested_attributes_for :days, reject_if: :all_blank
end
# app/models/day.rb
class Day < ApplicationRecord
belongs_to :trip
has_many :places, dependent: :destroy
accepts_nested_attributes_for :places, reject_if: :all_blank
end
# app/models/place.rb
class Place < ApplicationRecord
belongs_to :day
end

Fast JSON API Serializers

With the Fast JSON API gem bundled, I can use serializer generators to create serializers that customize the attributes to be rendered in JSON. They can be generated like so:

rails g serializer Trip
rails g serializer Day
rails g serializer Place

This will create a serializers folder within /app, and inside, trip_serializer.rb, day_serializer.rb, and place_serializer.rb.

The desired attributes and relationships to be rendered depend on the needs of your project. Below is my example:

# app/serializers/trip_serializer.rb
class TripSerializer
include FastJsonapi::ObjectSerializer
attributes :city, :lat, :lng
has_many :days
has_many :places, through: :days
end
# app/serializers/day_serializer.rb
class DaySerializer
include FastJsonapi::ObjectSerializer
attributes :date
has_many :places
belongs_to :trip
end
# app/serializers/place_serializer.rb
class PlaceSerializer
include FastJsonapi::ObjectSerializer
attributes :name, :place_id, :category
belongs_to :day
end

Rails Controllers

With the models prepared with accepts_nested_attributes_for, I can send HTTP requests to the outer-most resource inclusive of params for the inner resources. In my case, the outer-most resource is Trip, and the create action demonstrates the nesting very well.

# app/controllers/trips_controller.rb
class TripsController < ApplicationController
...
def create
trip = Trip.new(trip_params)
if trip.save
options = { include: %i[days places] }
render json: TripSerializer.new([trip], options).serialized_json
else
render json: { error: 'could not be created' }
end
end
...
private
def trip_params
params.require(:trip).permit(:city,
:lat,
:lng,
days_attributes: [:date,
places_attributes: %i[name
place_id
category]])
end
end

Note the triple nesting in the strong params: days_attributes inside the permit, and places_attributes inside days_attributes. This ensures that one POST request, hitting the TripsController’s create action, can create all the data points that belong to the trip.

This concludes the work in the backend. The following section explains the frontend work.

Frontend

POST Request for Create

From the data on the DOM, I need to construct the body of the POST request that conforms to the strong params with triple nesting accepted by the backend controller. It is very likely that the function to create this object (body) involves two map() methods, like so:

const createTripObj = () => {
// map through all day boxes on the DOM to get the days_attributes params array
const days_attributes = [...document.querySelectorAll('.daily')].map(dayBox => {
const date = dayBox.dataset.date;
// map through the place items inside this day box to get the places_attributes params array
const places_attributes = [...dayBox.querySelectorAll('.place-item')].map(placeItem => {
const name = placeItem.querySelector('.place-name').innerText;
const place_id = placeItem.dataset.placeId;
const category = placeItem.dataset.type;
return { name, place_id, category };
})
return { date, places_attributes };
})
const city = state.cityName; // global variable
const lat = state.mapCenter.lat(); // global variable
const lng = state.mapCenter.lng(); // global variable
// return a structure same as the triple nested params
return {
trip: { city, lat, lng, days_attributes }
};
}

If the above code is unclear, please reference the screenshot at the beginning of this post for some visualization. Essentially, this function returns an object structured in the below fashion, for example:

{
"trip" : {
"city" : "Washington",
"lat" : "8.9071923",
"lng" : "-77.0368707",
"days_attributes" : [
{
"date" : "Fri, 21 Aug 2020 21:56:07 GMT",
"places_attributes" : [
{
"name" : "Eastern Market",
"place_id" : "ChIJY8iLSTK4t4kRODLIp7hlw1Q",
"category" : "see"
},
{
"name" : "National Mall",
"place_id" : "ChIJMT3_Wpu3t4kRQScGokyrCDo",
"category" : "see"
}
]
}
]
}
}

With the tripObj structured exactly like the trip_params in the Rails TripController, inclusive of day and place information, I can send the data through a fetch request, like so:

newTrip = tripObj => {
const configObj = {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accepts": "application/json"
},
body: JSON.stringify(tripObj)
}
fetch('http://localhost:3000/trips', configObj) // use real server URL
.then(res => res.json())
.then(json => parseAndAddElement(json)); // parse response for rendering
}

Conclusion

Based on my experience, one of the most important skills for a full-stack implementation is the ability to traverse through data, no matter how it’s presented, for example, on the DOM or in JSON. This fundamental skill allows me to arrange and manipulate data with whichever programming language I am required to use. The triple nested resource written about in this post is just one of the easier examples. As I go through the software engineering program with the Flatiron School, I will continue to build this skill to be comfortable with different complex data structures.

👈 back
Copyright © Shiyun Lu 2020 - designed and developed by mePowered by &