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.


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.

# quote.rb
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:

{
  "content": "Smile. It confuses people.",
  "attribution": "Anonymous",
  "category": "Humor",
  "topic": "Keeping it light"
}

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

ActiveModel::UnknownAttributeError (unknown attribute 'category' for Quote.)

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:

# quote.rb
def initialize(args)
  create_category(args)
  create_topic(args)
  super(args)
end

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:

# quote.rb
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:

{
    "id": 9,
    "content": "Smile. It confuses people.",
    "attribution": "Anonymous",
    "topic": "keeping it light",
    "category": "humor"
}

See? Humor.

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

validates :content, :attribution, :topic, presence: true

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:

# quotes_controller.rb
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:

{
    "status": 400,
    "error": "Bad Request",
    "exception": "#<ActionController::ParameterMissing: param is missing or the value is empty: topic>",
    "traces": ...
}

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.

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.

# application_controller.rb
rescue_from 'ActionController::ParameterMissing', with: :render_error
protected
  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.

{
    "error": "param is missing or the value is empty: topic"
}

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:

# routes.rb
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:

# quotes_controller.rb
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:

Catch ya later!

alexa anderson

About the Author

Alexa Anderson is an engineer and evangelist with a penchant for making products geared for progress and achievement. When not writing software, Alexa spends her time curating content and sharing her discoveries.