Photo by Devon Divine on Unsplash
Building a Reading Log App with the Google Books API and Ruby on Rails - Part 4
Creating Book records by passing fetched API data from a view to a controller and permitting jsonb data types in strong params
Table of contents
In part 3 of this series, we learned about ActiveRecord associations and joined tables. We then used these concepts to create the ability to add books to specific shelves. Unfortunately, we had to do everything from a Rails console, and the books we created and organized didn't utilize the data we could fetch from Google's Books API. Our foundation is set in the context of the Model-View-Controller (MVC) architectural pattern. Having described logical relationships and interactions between models and our database, we can now move up the MVC pattern. In this article, our focus is engineering the ability for users to search for a book, create a database record from the response data, and associate that book with a shelf of their choosing, all from a web-based user interface!
We need some routes, views, and controller actions for books and shelves to achieve this functionality. You may or may not have noticed that our app already has most of our needs. But how? In part 2 of this series, we ran the following commands to create migration files for Book and Shelf automatically:
rails generate scaffold Book title:string authors:jsonb description:text page_count:integer categories:jsonb image_links:jsonb
rails generate scaffold Shelf name:string
What I did not point out at the time is that the scaffold
part of each command creates all of the essential files for each resource our app needs. In addition to migrations and models, this includes views, controllers, and a resourceful route for Book and Shelf. Score another point for the Rails doctrinal pillar of optimizing for programmer happiness!
However, just as construction crews erect scaffolding to make the job of building more accessible, the scaffolding doesn't lay bricks and mortar. The crews do. And so will we. Let's begin with routes.
Routing
Every Rails app is generated with a router. We can see ours by navigating to config/routes.rb
. If you've followed the previous tutorials in this series, your router should look like this:
Rails.application.routes.draw do
resources :shelves
resources :books
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
get '/search', :to => "searches#google_books_search"
# Defines the root path route ("/")
# root "articles#index"
end
The Rails.application.routes.draw do . . . end
block wraps our route definitions to establish the scope for our router DSL. You don't need to understand what that means specifically to appreciate its need in our app.
You do need to understand what our route definitions are doing.
resources :shelves
resources :books
A look at the Rails docs explains that declaring a resource in our router as we've done above quickly declares "all of the common routes for a given resourceful controller." Our app has mapped URL requests to their corresponding HTTP actions in the shelves or books controller for both Shelf and Book, respectively.
Though we don't need to do anything to our shelves or books resourceful routes, we need to define a root route, which will render the default view anytime we start up our application.
Your app might show a commented out root route similar to:
# Defines the root path route ("/")
# root "articles#index"
We don't have an articles controller with an index action, so let's tell our router DSL to render the index view for the shelf resource by default.
# Defines the root path route ("/")
root "shelves#index"
Let's now test out our root route by spinning up our Rails server in the command line by navigating into the directory for our app and executing rails server
.
You'll know your server is running when you see output similar to:
Puma starting in single mode...
* Puma version: 5.6.4 (ruby 2.7.5-p203) ("Birdie's Version")
* Min threads: 5
* Max threads: 5
* Environment: development
* PID: 26124
* Listening on http://127.0.0.1:3000
* Listening on http://[::1]:3000
Use Ctrl-C to stop
In a browser of your choosing, navigate to the port our server is listening to with http://localhost:3000/
. You should see a basic user interface similar to the image below.
In addition to the Shelves view, you should also see a search box and button resulting from our work in part 1 of this series. Search for a book's title or author's name and see what happens.
Views
Oof! We've run into a problem. Let me take an opportunity to share one of the most valuable lessons I've learned about programming. Problems are here to help. Often error messages like the one rendered in the image above tell us what the problem is and what we should do to solve it. Looking closely at our problem, we can see the solution. Notice the error message's note section: Rails expects an action to render a template with the same name, contained in a folder named after its controller.
At the top of the error, we're told what controller and action are missing a template: SearchesController#google_books_search
.
We can address this by creating a google_books_search.html.erb
file in an app/views/searches
subdirectory.
At this point, you can restart your server and search for another book to see that the error is no longer rendered. Instead, we see our search box above an otherwise blank page.
For our next step, I provide the contents for the google_books_search.html.erb
file below so we can walk through what is going on with a view of the entire file.
# app/views/searches/google_books_search.html.erb`
<% if @response %>
<% @response["items"].each do |item| %>
<% book = item["volumeInfo"]%>
<%= book["title"] %> by <%= book["authors"]&.join(", ") %>
<%= form_tag books_path do %>
<%= select_tag('shelf', options_from_collection_for_select(Shelf.all, "id", "name")) %>
<%= hidden_field(:book, :title, :value => book["title"]) %>
<%= hidden_field(:book, :description, :value => book["description"]) %>
<%= hidden_field(:book, :page_count, :value => book["pageCount"]) %>
<% book["authors"]&.each_with_index do |author, index| %>
<%= hidden_field(:book, "authors[#{index}]", :value => author) %>
<% end %>
<% book["categories"]&.each_with_index do |category, index| %>
<%= hidden_field(:book, "categories[#{index}]", :value => category) %>
<% end %>
<% book["imageLinks"]&.each do |key,value| %>
<%= hidden_field(:book, "image_links[#{key}]", :value => value) %>
<% end %>
<%= submit_tag "Create" %>
<% end %>
<hr>
<% end %>
<% else %>
No search results.
<% end %>
The code above is little more than an if-else-end conditional statement. If the @response
instance variable exists, we do stuff with the value assigned to that variable. If it doesn't exist, we render "No search results." As a reminder, we wrote the code for posting the results of our Books API search request to @response
in part 1 of this series. That code is in app/controllers/searches_controller.rb
and should look like this:
# app/controllers/searches_controller.rb
class SearchesController < ApplicationController
require 'google_api'
def google_books_search
@response = GoogleApi.new(params[:search]).query
end
end
Regardless of what search terms we use, any valid response to our API call is received by our app as a JSON hash. At the top level of this hash is the "items"
key, which has the value of an array of hashes representing the books returned from our search. Inside the if @response
portion of our conditional, we first format and display each book's title and authors in the response.
Note here my attempt to DRY up our code by assigning
item["volumeInfo"]
to a localbook
variable. This allows us to reference nested attributes for each book with the more intuitivebook
name than the ambiguous Books API nested structure whenever we write code to do something with a given book's attributes.
# app/views/searches/google_books_search.html.erb`
<% if @response %>
<% @response["items"].each do |item| %>
<% book = item["volumeInfo"] %>
<%= book["title"] %> by <%= book["authors"]&.join(", ") %>
...
<% end %>
<% else %>
No search results.
<% end %>
With only the portion of code in the above example, executing a search renders the title and authors for books in the response like this:
It's not much to look at yet. But it's pretty cool to see that we have removed our dependency on the Rails console for interacting with the Books API!
The rest of the code in our if
block makes it possible to see book data in a rendered view and create our book records in our PostgreSQL database. Let's break down what is precisely going on.
...
<%= form_tag books_path do %>
<%= select_tag('shelf', options_from_collection_for_select(Shelf.all, "id", "name")) %>
<%= hidden_field(:book, :title, :value => book["title"]) %>
<%= hidden_field(:book, :description, :value => book["description"]) %>
<%= hidden_field(:book, :page_count, :value => book["pageCount"]) %>
<% book["authors"]&.each_with_index do |author, index| %>
<%= hidden_field(:book, "authors[#{index}]", :value => author) %>
<% end %>
<% book["categories"]&.each_with_index do |category, index| %>
<%= hidden_field(:book, "categories[#{index}]", :value => category) %>
<% end %>
<% book["imageLinks"]&.each do |key,value| %>
<%= hidden_field(:book, "image_links[#{key}]", :value => value) %>
<% end %>
<%= submit_tag "Create" %>
<% end %>
...
From a high-level perspective, we are creating an embedded form with the ability to submit form data. You'll notice however that with all of the code for this file present, when we search for books, we don't see all of the data in the UI, take page_count
as an example. If it's not visible, are we really submitting it when we submit our form? Yes!
Rails offer us loads of helper methods that make tasks easier to do. hidden_field
is one such method made available to us as part of the ActionView::Helpers::FormTagHelper
subclass. As explained in the docs, the hidden_field
helper, "returns a hidden input tag tailored for accessing a specified attribute on an object."
We utilize this helper method for sending all of the book fields we need in our form submission. But the implementation of authors, categories, and imageLinks requires a little bit of setup. The reason is that these fields in our database have the jsonb
type. We used jsonb
to allow us to potentially store multiple values (such as two or more authors) in the same database field (e.g. authors
) for a given Book record.
<% book["authors"]&.each_with_index do |author, index| %>
<%= hidden_field(:book, "authors[#{index}]", :value => author) %>
<% end %>
<% book["categories"]&.each_with_index do |category, index| %>
<%= hidden_field(:book, "categories[#{index}]", :value => category) %>
<% end %>
<% book["imageLinks"]&.each do |key, value| %>
<%= hidden_field(:book, "image_links[#{key}]", :value => value) %>
<% end %>
What the above code is doing is the same for authors and categories and similar for imageLinks. We iterate through the array we get from the Books API response using the each_with_index
helper method for authors
and categories
and the .each
helper method for imageLinks
. In all three of these enumerable blocks we then create a unique hidden_field
element for each potential value, storing values to indexes in an array or key-value pairs depending on the field.
When we refresh our browser and make another search, our response should now look more organized.
The last concept to explain here is the select input for shelves seen with each response item.
The screenshot above shows To be Read
as the default (and only shelf option) as a result of manually creating that shelf via Rails console in part 2 of this series. If you deleted your records or didn't create any shelves in the first place, your select will be empty. That being said, our select_tag
helper method needs to display all of a user's shelves, considering that we intend to provide a collection of default shelves to our users.
<%= select_tag('shelf', options_from_collection_for_select(Shelf.all, "id", "name")) %>
We achieve this by using Rails' options_from_collection_for_select
method where we pass all Shelf
class instances as our "collection". The method will query for all of our shelf records. It then assigns the value of each option as the record's id
and the visible text for each option as the record's name
.
Creating a Book Record from the UI
With a good understanding of the google_books_search.html.erb
code we are almost ready to start adding books to our database. If you were to search a book, select a shelf to add it to, and attempt to create the book record now, you would likely run into a model validation error for authors
, categories
, and image_links
. Remember, those database fields have the jsonb
type.
To handle this, we need to update the syntax of our strong parameters in app/controllers/books_controller.rb
like this:
# Only allow a list of trusted parameters through.
def book_params
permitted_params = params.require(:book).permit(:title, :description, :page_count, authors: params["book"]["authors"].keys, categories: params["book"]["categories"].keys, image_links: params["book"]["image_links"].keys)
end
In addition to permitting our jsonb
values, using the .keys
method adds the flexibility to permit a dynamic number of key-value pairs, so we can store any number of image links the Books API provides for a book in a given response (it's not always the same).
Well done!
Conclusion
Together, we learned how to:
understand the router DSL and add a root route
debug problems by dissecting the information in an error message
create new views not generated with Rails'
scaffold
commanduse the
hidden_field
helper to submit "invisible" values to a controller in the params hash, andupdate strong paramaters in a controller to handle
jsonb
data types.
In part 5️⃣ of this series, we're going to do something awesome! We are going to add the ability for multiple users to create their own unique accounts in our app (a concept called multitenancy) and automatically generate a default collection of shelves for each user when they create their account.