Rails Engine

Turing School of Software Design, Module 3, Week 1

This week at Turing we built an Application Program Interface (API), which can be found in this repository.  You can interact with this API on our Heroku Landing Page to test it out and understand all the functionality. 

We revisited the Sales Engine dataset from Black Thursday, but instead of running business intelligence queries in straight Ruby, we built a project that can take various HTTP requests for data and respond with the requested data using Ruby on Rails, ActiveRecord, and a PostgreSQL. Personally, this project represented an interesting milestone in my coding journey.

When I left New Visions for Public Schools, a school support organization in New York City, my team had just started to build an API in JavaScript to internally serve student data to our various tools. At that point I believed my skills and interests were more solidly in the education world so I chose to pursue school leadership in Denver. Now I am back in technology and I have just completed a project similar to the one that basically scared me out of the technology world just two years ago.

The learning goals for this project were to:

  • Learn how to to build Single-Responsibility controllers to provide a well-designed and versioned API.
  • Learn how to use controller tests to drive your design.
  • Use Ruby and ActiveRecord to perform more complicated business intelligence.

The project has three parts. The first, and most straightforward step was to establish record endpoints for each of the six Sales Engine data types: Merchants, Transactions, Customers, Invoices, Items, and Invoice Items. The project specification divides this task in half with each partner taking three of the six endpoints. However, since each of these data types has a relationship with all the others I would recommend that future Turing Module 3 students just do the initial database migrations together. The problem to avoid here is trying to create a table with a foreign key from another table that does not yet exist because one's partner is responsible for creating it.

Here is the schema we ended up with:

ActiveRecord::Schema.define(version: 6) do

  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"
  enable_extension "citext"

  create_table "customers", force: :cascade do |t|
    t.citext   "first_name"
    t.citext   "last_name"
    t.datetime "created_at"
    t.datetime "updated_at"
  end

  create_table "invoice_items", force: :cascade do |t|
    t.integer  "item_id"
    t.integer  "invoice_id"
    t.integer  "quantity"
    t.integer  "unit_price"
    t.datetime "created_at"
    t.datetime "updated_at"
    t.index ["invoice_id"], name: "index_invoice_items_on_invoice_id", using: :btree
    t.index ["item_id"], name: "index_invoice_items_on_item_id", using: :btree
  end

  create_table "invoices", force: :cascade do |t|
    t.integer  "customer_id"
    t.integer  "merchant_id"
    t.citext   "status"
    t.datetime "created_at"
    t.datetime "updated_at"
    t.index ["customer_id"], name: "index_invoices_on_customer_id", using: :btree
    t.index ["merchant_id"], name: "index_invoices_on_merchant_id", using: :btree
  end

  create_table "items", force: :cascade do |t|
    t.citext   "name"
    t.citext   "description"
    t.integer  "unit_price"
    t.integer  "merchant_id"
    t.datetime "created_at"
    t.datetime "updated_at"
    t.index ["merchant_id"], name: "index_items_on_merchant_id", using: :btree
  end

  create_table "merchants", force: :cascade do |t|
    t.citext   "name"
    t.datetime "created_at"
    t.datetime "updated_at"
  end

  create_table "transactions", force: :cascade do |t|
    t.integer  "invoice_id"
    t.text     "credit_card_number"
    t.text     "credit_card_expiration_date"
    t.citext   "result"
    t.datetime "created_at"
    t.datetime "updated_at"
    t.index ["invoice_id"], name: "index_transactions_on_invoice_id", using: :btree
  end

  add_foreign_key "invoice_items", "invoices"
  add_foreign_key "invoice_items", "items"
  add_foreign_key "invoices", "customers"
  add_foreign_key "invoices", "merchants"
  add_foreign_key "items", "merchants"
  add_foreign_key "transactions", "invoices"
end

The spec tasks us with creating an endpoint for each of the above data types that will show an individual record and index all of the records. We also needed to create a single finder endpoint for each data type so that a consumer of our API could request a single record or set of records based on a search query attached to the end of the url. For example: 

GET /api/v1/merchants/find?parameters

and for multi-finders:

GET /api/v1/merchants/find_all?parameters

Testing Controllers

Rails Engine has a reputation of being one of the most difficult projects at Turing and setting up the initial endpoints is just the trail you take to get to the extreme terrain. The real challenge begins with implementing API endpoints that return business logic data that gets generated by ActiveRecord and SQL queries. The implementation of these endpoints always begins with a request test, like the following for finding the date that a given item had the most sales (in terms of quantity).

require 'rails_helper'

describe "get request to single item's best day" do
  it "returns date (or dates in case of tie) with most sales for item by invoice date" do
    item_1 = create(:item)
    invoice_most = create(:invoice)
    invoice_least = create(:invoice)
    invoice_item_most = create(:invoice_item,
                                item_id: item_1.id,
                                invoice_id: invoice_most.id,
                                quantity: 2)
    invoice_item_least = create(:invoice_item,
                                item_id: item_1.id,
                                invoice_id: invoice_least.id,
                                quantity: 1)

    get "/api/v1/items/#{item_1.id}/best_day"
    date = JSON.parse(response.body)
    expected_date = invoice_most.created_at.to_i
    actual_date = DateTime.parse(date["best_day"]).to_i

    expect(response).to be_success
    expect(date).to be_instance_of(Hash)
    expect(actual_date).to eq(expected_date)
  end
end

Let's look at this test chunk by chunk starting with the set up:

require 'rails_helper'

describe "get request to single item's best day" do
  it "returns date (or dates in case of tie) with most sales for item by invoice date" do
    item_1 = create(:item)
    invoice_most = create(:invoice)
    invoice_least = create(:invoice)
    invoice_item_most = create(:invoice_item,
                                item_id: item_1.id,
                                invoice_id: invoice_most.id,
                                quantity: 2)
    invoice_item_least = create(:invoice_item,
                                item_id: item_1.id,
                                invoice_id: invoice_least.id,
                                quantity: 1)

In the setup we describe that our API should have a request to a single item's best day. We state that the API should respond to such a request with the date with the most sales for the item based on invoice date. 

Next, we perform a little database setup. To test this functionality we need an item in order to check its best day. We also need two invoices. We would expect our query to return the date from the invoice with the greatest quantity of our item. Quantity is stored on invoice_items, a join table between our invoices table and our items table. So, we'll need two invoice_items as well; one with a larger quantity (2) than the other. Now we should have everything we need to query our database for the best day of a given item - an item, two invoices, and two invoice items.

get "/api/v1/items/#{item_1.id}/best_day"
date = JSON.parse(response.body)
expected_date = invoice_most.created_at.to_i
actual_date = DateTime.parse(date["best_day"]).to_i

Next we make a get request to the path that we will use for our query - /api/v1/items/:id/best_day. We store the API's response in a variable called date. We create an expected_date variable to test our actual date against. In this case, this would be the created_at value from the invoice tied to the invoice item with the larger quantity of our item. We also turn that expected_date into an integer to make comparison easier. In order to avoid dealing with dates at all costs, we take the response date, which is a hash, grab the value keyed to "best day", parse it into a DateTime object, and then convert it to an integer. Not pretty, suggestions welcome.

expect(response).to be_success
expect(date).to be_instance_of(Hash)
expect(actual_date).to eq(expected_date)

Lastly, we write our expectations. We expect the API response to be successful. We expect the returned date to be a Hash object. And most importantly, we expect the actual returned date to equal our expected best day.

When I wrote this test, all the models and factories (we used Factory Girl to mock up our Ruby objects) were already set up, so the first error comes from our get request. To solve this error, we need to setup the route our test is expecting to see:

Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      namespace :items do
        get ':id/best_day',                         to: 'best_day#show'
      end
    end
  end
end

I simplified the above code snippet for clarity. There are many more routes for items and other models in our routes file, which you can find on our GitHub repository. What the above code says is when the API receives a get request for /api/v1/items/:id/best_day, direct that request to the show method (because it's a singular day we want to return) on the Api::V1::Items::BestDayController.

Single Responsibility Controllers

One of the goals of this project was to gain experience creating single responsibility controllers. Going back to the Model, View, Controller (MVC) architectural pattern, controllers direct incoming requests to the appropriate model data and render the appropriate view. Since this is an API and there are no views, the controller renders a response in JavaScript Object Notation (JSON), which is special text format that is easily readable by both computers and humans. Our best day Controller is spartan; all of the business logic is pushed into the Item model and we are left with:

class Api::V1::Items::BestDayController < ApplicationController
  def show
    render json: Item.find(params["id"]).best_day
  end
end

Active Record and Business Intelligence

The final step is to create a best_day method in the Item model that will return an instance of an Item's best day. At the end of Module 1 we did similar types of queries in Ruby with various enumerables. This is a slower approach than using ActiveRecord and SQL to directly query the database for exactly the data we want. So for querying the best items for a given merchant by both quantity and revenue generated in Module 1, we would do something like:

class MerchantItemAnalyst
  attr_reader :merchant_id,
              :analyst

  def initialize(merchant_id, analyst)
    @merchant_id = merchant_id
    @analyst = analyst
  end

  def merchant_paid_in_full_invoices
    analyst.merchants.find_by_id(merchant_id).invoices.find_all do |invoice|
      invoice.is_paid_in_full?
    end
  end

  def merchant_paid_in_full_invoice_items
    merchant_paid_in_full_invoices.map do |invoice|
      analyst.invoice_items.find_all_by_invoice_id(invoice.id)
    end.flatten
  end

  def group_invoice_items_by_quantity
    merchant_paid_in_full_invoice_items.group_by do |invoice_item|
      invoice_item.quantity
    end
  end

  def group_invoice_items_by_revenue
    merchant_paid_in_full_invoice_items.group_by do |invoice_item|
      invoice_item.price
    end
  end

  def max_quantity_invoice_items
    invoice_items = group_invoice_items_by_quantity
    invoice_items[invoice_items.keys.max]
  end

  def max_revenue_invoice_items
    invoice_items = group_invoice_items_by_revenue
    invoice_items[invoice_items.keys.max]
  end

  def most_sold_item_for_merchant
    max_quantity_invoice_items.map do |invoice_item|
      analyst.items.find_by_id(invoice_item.item_id)
    end
  end

  def best_item_for_merchant
    max_revenue_invoice_items.map do |invoice_item|
      analyst.items.find_by_id(invoice_item.item_id)
    end.first
  end

end

That's a lot of iterating. ActiveRecord and SQL and do all of this for us. Now to find an item's best day we can write:

def best_day
  {"best_day" => invoices.joins(:invoice_items)
    .order("invoice_items.quantity DESC, invoices.created_at DESC")
    .first.created_at}
end

In the above method, which is part of our Item model, we grab all of the invoices associated with a single item. Next we join that with the invoice_items table. My understanding is that the join grabs all the invoice items associated with the invoices that are associated with the given item. Next we use the ActiveRecord method order, to sort our records by first the quantity on the invoice_items and then the date they were created. The specifications state that in the case of a tie for quantity, return the most recent date. Finally we call .first to grab the first invoice on our list and call .created_at to get its date.

Concluding Thoughts

Rails Engine marks the more than halfway point for my progress at Turing. Reflecting back to what I knew just a few weeks ago I'm satisfied with my progress. Additionally, this week was highly satisfying in terms of instructional structure. Josh Mejia, our lead instructor, put a few key lessons on Monday but reserved the rest of the week for work and struggle. During the work time we could explore the project's challenges on our own and learn by doing. I was also able to request checkins with Josh to help me with the more complicated business logic. Had I gotten this in the form of a one and a half hour lecture, the instruction would have been far less relevant than getting about 20 minutes of personalized learning in the context of my own project.

My project partner Nate Anderson and I also got to pair with a Module 4 student, Pat Wetnz, who had a much tighter handle on the ins and outs of SQL and Active Record. This also speaks to the kind of culture we have at Turing. The rigor of the program builds a level of comradery in which everyone understands the struggles of their fellow students and no one is ever too busy to answer a question.