Ah yes, the Rails Controller, a source of much contention among Rails developers. So many different ways to manage control flow, load objects, respond in standard and erroneous ways. My opinion up until recently was "I'll just put a bunch of conditionals in there for different situations."
Recently, I've been working more on API endpoints and so responding with nice error messages has been more of a priority. I started using Exceptions more throughout my code thanks to Avdi Grimm, and I recently wrote and action that I'm particularly proud of. Check it out:
# This controller's job is to exchange twitter credentials for Shortmail credentials
class TwitterReverseAuthController < ApplicationController
# First, let's make our own subclass of RuntimeError
class Error < RuntimeError; end
def api_key_exchange
# Here are our required parameters. If any are missing we raise an error
screen_name = params.fetch(:screen_name) { raise Error.new('screen_name required') }
token = params.fetch(:oauth_token) { raise Error.new('oauth_token required') }
secret = params.fetch(:oauth_secret){ raise Error.new('oauth_secret required') }
# OK now let's authenticate that user. If we can't find a valid user, raise an error
@user = User.by_screen_name(screen_name).where(
:oauth_token => token,
:oauth_secret => secret
).first or raise Error.new('user not found')
# Now we'll build a device. I'm not catching an exception on create! here because
# It should never fail. (I.e. a failure is actually a 500 because we don't expect it)
@device = Device.find_or_create_by_token!(
params.slice(:token, :description).merge(:user_id => @user.id)
)
render :json => { :api_key => @device.api_key }
# Now I can simply catch any of my custom exceptions here
rescue Error => e
# And render their message back to the user
render :json => { :error => e.message }, :status => :unprocessable_entity
end
end
Here are the things I really like about this solution:
- The happy path is really clear because there's no if/else branching
- Errors are really obvious because I'm raising an exception (as opposed to "else render json that complains" which looks like a render which is not immediately apparent as a failure)
- It's super easy to handle the errors in the same way. Instead of repeating the json render with a different message all throughout the method (i.e. it's DRY)
How's this look to you? How do you organize controller control flow?
But this will only catch one error at a time, so you'd return the result when the first raise is hit even if multiple parameters are missing.
And Avdi points out that there is performance overhead with raising exceptions. Did you opt not to worry about that? Why not just collect an array of errors and test for their presence?
Also keep in mind it's more performant the sooner I can bail out of processing the request :-)
Collecting and returning all errors makes sense for fetching the params, but if I can't find a user I can't proceed with the rest of the call. So I have to stop execution anyways.
- From my limited reading of Avdi's blog posts / presentation slides, I don't feel that he would agree with using exceptions this way.
- If you really want to subclass RuntimeError, I would choose a more descriptive name.
An alternative: https://gist.github.com/1243758
@james I'm looking up a user and creating a device. This is the minimum amount that can be done in a nested route on a create action. I disagree with your extraction because it will be only used in this single situation and obfuscates the method.
I might make a User.authenticate_via_oauth(screen_name, token, secret) that returns nil or a user, but creating a separate class is overkill here IMO.
* So many wonderful fetches! My cup overflows.
* Nice use of 'or' as a statement modifier too, putting the error case last, where it belongs :-)
* Maybe I'm missing something... why are you explicitly instantiating the exceptions? Why not 'raise Error, "some message"'?
* Because this is in an API endpoint, it makes sense to use exceptions liberally. We expect humans to make occasional mistakes. Conversely, we expect API clients to be fixed when they make mistakes, and then to never make that mistake again. We also generally don't need to present "Here's what you said, maybe you meant something else..." type feedback to robots, so we don't need to worry about keeping context around that the exceptions might throw away.
* As james pointed out, I do think there's a lot going on in this method. E.g. I personally don't think a #where() call has any business in a controller, and then you've got a #first on the end of that, which makes it a third-order digression into querying minutia.
@Jim: Exception performance is not on the order to worry about in a case like this. It only becomes a worry inside tight loops. Here network latency is going to drown any latency the exceptions add.
Yeah the "where" on user should be "User.authenticate_via_oauth(screen_name, token, secret) => user or nil"
Thanks for the feedback everyone. Glad to know the controller is still an interesting area to experiment with.
https://gist.github.com/1245259
fork it!
https://gist.github.com/1245327
https://gist.github.com/1245342
your incorrect assumption is that every api call fails. When 1 per 1 million calls fail, exceptions are 3% slower, which is acceptable for readability purposed.
http://www.enlightsolutions.com/articles/catch-all-exception-handling-is-not-flow-control/
It feels like you're using Exceptions for flow control. At least I don't see missing parameters as an exceptional case. It's possible and expected to happen. That's why we test for it.
Thanks.