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

·

10 min read

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.

Screen Shot 2022-05-01 at 10.37.14 AM.png

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.

Screen Shot 2022-05-01 at 10.44.53 AM.png

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 local book variable. This allows us to reference nested attributes for each book with the more intuitive book 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:

Screen Shot 2022-05-01 at 11.35.37 AM.png

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.

Screen Shot 2022-05-01 at 12.29.03 PM.png

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).

Screen Shot 2022-05-01 at 7.22.01 PM.png

Well done!

Conclusion

Together, we learned how to:

  1. understand the router DSL and add a root route

  2. debug problems by dissecting the information in an error message

  3. create new views not generated with Rails' scaffold command

  4. use the hidden_field helper to submit "invisible" values to a controller in the params hash, and

  5. update 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.