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
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"
}
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.
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!
Written: March 29, 2024
Last updated: January 12, 2025