Quotes API. Keep It Simple.

Last we left, I initialized a simple Rails API that can create and dish out positive quotations. Next, I defined the resources that my application would deal with, and threw together the data models that tied them together. Let’s keep this simplicity train rolling with the next logical step: the user API.

Image for post
Image for post
Something like this, but without all of the buttons. Also, nothing like this.

to_json

Before we get to the business, I’m going to make my life easier by adding a custom to_json method for the Quote class.

def to_json(opts = {})
{
id: id,
content: content,
attribution: attribution,
category: category.title,
topic: topic.title
}
end

Under the hood, this is called when we render a quote as JSON in the controllers. The render method will pass an options hash to the function, so we stub out the parameter to avoid an argument error.

Using this, we can troubleshoot the topic and category associations in the following code.

The Model

My end goal is to have an endpoint that accepts a few pertinent fields and needs no configuration to create and save a desired quote. The JSON payload will look like:

If I try running something like this through right now, I get a big, bad error:

Also, there is a foreseeable error of the Quote model attempting to initialize with its topic as a string rather than a instance of the class Topic.

While this appears to be a case for ActiveModel lifecycle hooks, these errors will actually occur during the initialization of the Quote object, before any of the available lifecycle hooks get called. My solution around this is to rewrite the Quote class’ initialize method:

def initialize(args)
create_category(args)
create_topic(args)
super(args)
end
Image for post
Image for post
Never forget the super.

Category creation must happen first so that the Topic can be created with the association. The two functions are implemented like so:

def create_topic(args)
if !args[:topic]
return args[:topic] = Topic.uncategorizedTopic
end
title = args[:topic].downcase
category = args.delete(:category) { |cat| return cat }
new_topic =
Topic.find_or_create_by(title: title, category: category)
args[:topic] = new_topic
end
def create_category(args)
if !args[:category]
return args[:category] = Category.uncategorizedCategory
end
title = args[:category].downcase
args[:category] =
Category.find_or_create_by(title: title)
end

This works by converting the title strings passed in from the JSON params to be actual Topic and Category objects.

With association creation in place, sending the same payload now returns the created quote:

Image for post
Image for post
See? Humor.

I’m going to finish locking down this model by adding presence validation for topic.

All of the above work keeps the model properly restricted within the application’s environment, e.g. rails console or other modules. Now, I’ll go to the controller and to ensure outside users send in consumable data.

The Controller

For my purposes, I simply want to ensure that the params come in with topic and category defined. I’ll chain onto my previously defined quote_params method to achieve this:

before_action :ensure_create_params, only: :create...def quote_params
params.require(:quote).permit(
:id,
:content,
:attribution,
:category,
:topic
)
end
def ensure_create_params
quote_params.require([:category, :topic])
end

With those params now required, if one isn’t supplied, Params#require will raise an ApplicationController::ParameterMissing error. The end user’s response will look something like this:

Traces is a nested hash of both application level and framework level stack traces. I expect (hope) non-programmy folks will be consuming this API as well, and having 150 lines of stack traces because of a missing parameter is, in a word, gross.

Image for post
Image for post
Simulated reaction of future consumers

My solution here is to add a ParameterMissing rescue block. I’m going to put it in the ApplicationController because A) it simply isn’t specific to Quotes or their controller, and B) while I only have one controller now, I do not want to revisit this for the ones that may come later.

rescue_from 'ActionController::ParameterMissing', with: :render_errorprotected  def render_error(error)
render json: { error: error }, status: 400
end

The callback passed to rescue_from receives the error message. I’ll use that to send the relevant error back to the user with the proper status code header.

Let’s Get RaNdOm

The quotes_controller's show action still as intended. I’m not going to add one more route and action so that I personally can consume this API to my desires.

For my purposes, I want to be able to request a random quote from the app without specifying a category or quote id. I’ll make a /random route and a corresponding controller action to handle that. The alternate here is to use a ?random=true query parameter, but in my experience, query parameters are better for specifying sorting or filters rather than specifying how to gather the resource.

Within the :v1 scoping of the routes, I add:

scope :v1 do
get 'quotes/random', to: 'quotes#random'
resources :quotes, only: [:show, :create]
end

Notice the addition route is added above the resources route. This prevents conflict with the /quotes/:id route created within resources.

And the new random action of the controller:

def random
quote = Quote.order('RANDOM()').limit(1).first
render json: quote
end

I considered a few other queries before going with this one.

One option was choosing a random number from 1-Quote.count and finding a quote by id using the number calculated. That case has the issue of “faking” knowing whether a quote of a given id even existed. Let’s say in the future, I decide to remove half of the quotes in the DB, the half that was chronologically entered first. If I had 100 quotes originally, the quote I had now would only be quotes with ids 51–100, whereas this “random quote finder” would only return numbers 1–50.

An option that avoids that pitfall would be to use Quote.pluck('id'), then sample from the returned ids, and finally query for the quote based on the sampled id. There are a couple of issues with this one. First of all, a pluck operation must touch every record in the table. While, right now there are only a few records, this approach simply wont scale. The other issue is that the algorithm would hit the database twice: once for the ids and again for the target quote.

This chosen algorithm first my bill because it only executes one query and is guaranteed to return a quote.

And with that, the initial API is finished. The user never knows the modeling in the back end and never has to specify the id of any categories or topics. In fact, with the random route, the user never has to know any quote’s id.

There are many enhancements that can be made — such as querying by category or topic. Attributions could also be made into its own resource with an association to quotes. It’d be pretty cool to be able to ask for all quotes by Gandhi, for instance.

If you’d like to contribute any enhancement, send in a PR on the official repository. If you were following along, checkout the branch for this post.

Next time, I’ll be zipping this bad boy up to the cloud for external users to consume and dealing with the CORS and other issues that will come with that.

Until then, I’ll leave you with a taste of what this API can provide:

Image for post
Image for post

Catch ya later!

Written by

Fullstack Developer and Code Yogi

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store