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 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.rbRails.application.config.middleware.insert_before 0, Rack::Cors doallow doorigins '*' # for development only, change to your origin in productionresource '*',headers: :any,methods: [:get, :post, :put, :patch, :delete, :options, :head]endend
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:decimalrails g resource day date:date trip:referencesrails 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.rbclass Trip < ApplicationRecordhas_many :days, dependent: :destroyhas_many :places, through: :daysaccepts_nested_attributes_for :days, reject_if: :all_blankend# app/models/day.rbclass Day < ApplicationRecordbelongs_to :triphas_many :places, dependent: :destroyaccepts_nested_attributes_for :places, reject_if: :all_blankend# app/models/place.rbclass Place < ApplicationRecordbelongs_to :dayend
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 Triprails g serializer Dayrails 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.rbclass TripSerializerinclude FastJsonapi::ObjectSerializerattributes :city, :lat, :lnghas_many :dayshas_many :places, through: :daysend# app/serializers/day_serializer.rbclass DaySerializerinclude FastJsonapi::ObjectSerializerattributes :datehas_many :placesbelongs_to :tripend# app/serializers/place_serializer.rbclass PlaceSerializerinclude FastJsonapi::ObjectSerializerattributes :name, :place_id, :categorybelongs_to :dayend
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.rbclass TripsController < ApplicationController...def createtrip = Trip.new(trip_params)if trip.saveoptions = { include: %i[days places] }render json: TripSerializer.new([trip], options).serialized_jsonelserender json: { error: 'could not be created' }endend...privatedef trip_paramsparams.require(:trip).permit(:city,:lat,:lng,days_attributes: [:date,places_attributes: %i[nameplace_idcategory]])endend
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 arrayconst 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 arrayconst 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 variableconst lat = state.mapCenter.lat(); // global variableconst lng = state.mapCenter.lng(); // global variable// return a structure same as the triple nested paramsreturn {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.