CreateSend refactoring part 1

Today I'm going to refactor a part of Ruby library called CreateSend. I'll work on lib/createsend/createsend.rb, which contains CreateSend::CreateSend class and some other stuff.

Introduction

CreateSend is a base class for accessing CampaignMonitor API. It provies .user_agent to set HTTP user agent and .exchange_token to get OAuth access token.

In summary, it's a Ruby wrapper for accessing API. Classes like Campaign inherit from CreateSend to add specific methods to work with campaigns, etc.

First look

module CreateSend
USER_AGENT_STRING = "createsend-ruby-#{VERSION}-#{RUBY_VERSION}-p#{RUBY_PATCHLEVEL}-#{RUBY_PLATFORM}"
# Represents a CreateSend API error. Contains specific data about the error.
class CreateSendError < StandardError
attr_reader :data
def initialize(data)
@data = data
# @data should contain Code, Message and optionally ResultData
extra = @data.ResultData ? "\nExtra result data: #{@data.ResultData}" : ""
super "The CreateSend API responded with the following error"\
" - #{@data.Code}: #{@data.Message}#{extra}"
end
end
# Raised for HTTP response codes of 400...500
class ClientError < StandardError; end
# Raised for HTTP response codes of 500...600
class ServerError < StandardError; end
# Raised for HTTP response code of 400
class BadRequest < CreateSendError; end
# Raised for HTTP response code of 401
class Unauthorized < CreateSendError; end
# Raised for HTTP response code of 404
class NotFound < ClientError; end

The first things declared are default user agent constant (line 3) and some exceptions.

What's good:

  • long string split into two (lines 12-13), instead of having overly long string.
  • thorough comments for error classes, detailing which HTTP error codes they represent (lines 17-26)

Step 1: concise public interface

When reading CreateSendError class (lines 6-15 ↑) I notice that there's too much information there that I don't really need to know (lines 10-13 ↑). I believe, formatting of error message can be moved to a private method, and people reading the code after that can just skip any private methods, because they aren't essential for understanding.

So, I extract method:

# Represents a CreateSend API error. Contains specific data about the error.
class CreateSendError < StandardError
attr_reader :data
def initialize(data)
@data = data
super format_data_as_message
end
private
def format_data_as_message
# @data should contain Code, Message and optionally ResultData
extra = @data.ResultData ? "\nExtra result data: #{@data.ResultData}" : ""
"The CreateSend API responded with the following error"\
" - #{@data.Code}: #{@data.Message}#{extra}"
end
end

Step 2: minor fixes

The comment on line 13 (see above ↑) doesn't really add anything to understanding. It's clear that the code references ResultData, Code and Message, so the comment just repeats what code tells. It is safe to remove it.

On line 14 (see above ↑), extra gets assigned an empty string if ResultData isn't present. nil interpolated into string will yield a "" as well, so there's no need to assign it.

Here's the result:

def format_data_as_message
extra = "\nExtra result data: #{@data.ResultData}" if @data.ResultData
"The CreateSend API responded with the following error"\
" - #{@data.Code}: #{@data.Message}#{extra}"
end

Step 2.1

On line 2 (see above ↑), extra variable is used to store extra result data. It's kinda easy to remember what's stored in the variable, since it's used on the next line (and we could have used a as the variable name). But I want to make it not necessary to remember at all!

What we really have here is not extra, but result data, to be precise, formatted result data. So, I'm going to use formatted_result_data instead:

def format_data_as_message
formatted_result_data = "\nExtra result data: #{@data.ResultData}" \
if @data.ResultData
"The CreateSend API responded with the following error"\
" - #{@data.Code}: #{@data.Message}#{formatted_result_data}"
end

I realise that that formatted isn't ideal name for variable contents concantenated with description. If you have a better idea, please leave it in the comments.

Step 2.2

Taking one last look at the CreateSendError class, I realise that it's not DRY, as it's inside CreateSend module, which makes it CreateSend::CreateSendError. Thus, I'm going to make it CreateSend::Error.

# Represents a CreateSend API error. Contains specific data about the error.
class Error < StandardError
...
end

Step 3: introducing CreateSend class

Take a look:

# Provides high level CreateSend functionality/data you'll probably need.
class CreateSend
include HTTParty
attr_reader :auth_details
# Specify cert authority file for cert validation
ssl_ca_file File.expand_path(File.join(File.dirname(__FILE__), 'cacert.pem'))
# Set a custom user agent string to be used when instances of
# CreateSend::CreateSend make API calls.
#
# user_agent - The user agent string to use in the User-Agent header when
# instances of this class make API calls. If set to nil, the
# default value of CreateSend::USER_AGENT_STRING will be used.
def self.user_agent(user_agent)
headers({'User-Agent' => user_agent || USER_AGENT_STRING})
end

We can see that the class uses a HTTP library HTTParty, has some notion of authentication, uses a certificate and can set HTTP user agent.

First thing I don't like about it, is that it's CreateSend::CreateSend class, not DRY at all. From the class comment and from looking at other code, I know that this class is used as a base (for example, Subscriber class is a descendent of it). So, it seems it'd be better to call it Base.

Next up, on line 7 is certificate setup. Too much going on that I don't need to know. Thus, I'm going to move that code to a separate module.

Here's the result:

# Provides high level CreateSend functionality/data you'll probably need.
class Base
include HTTParty
extend Certificate
attr_reader :auth_details
# Specify cert authority file for cert validation
ssl_ca_file cert_path('cacert.pem')

module Certificate
def cert_path(file_name)
File.expand_path(file_name, File.dirname(__FILE__))
end
end

Step 3.1: setting user agent

Take a look:

# Set a custom user agent string to be used when instances of
# CreateSend::Base make API calls.
#
# user_agent - The user agent string to use in the User-Agent header when
# instances of this class make API calls. If set to nil, the
# default value of CreateSend::USER_AGENT_STRING will be used.
def self.user_agent(user_agent)
headers({'User-Agent' => user_agent || USER_AGENT_STRING})
end

.user_agent sets up User-Agent HTTP header. It accepts a single argument, and if it's falsey, the default user agent is used instead.
user_agent nil or user_agent false is a pretty obscure way to say "please present yourself as Createsend to HTTP servers". I think .default_user_agent expresses it better.

The result:

# Set a custom user agent string to be used when instances of
# CreateSend::Base make API calls.
#
# user_agent - The user agent string to use in the User-Agent header when
# instances of this class make API calls.
def self.user_agent(user_agent)
headers('User-Agent' => user_agent)
end
# Set user agent to be CreateSend.
def self.default_user_agent
user_agent USER_AGENT_STRING
end

Step 4: class methods

There are 3 class methods left to refactor: .authorize_url, .exchange_token and .refresh_access_token. They all have to do with OAuth. Even though I've never had to deal with OAuth, I'll have no problem refactoring them.

Step 4.1: authorize_url method

authorize_url sounds like it authorizes something, but in fact, it just returns an URL constructed from its arguments. Take a look:

# Get the authorization URL for your application, given the application's
# client_id, redirect_uri, scope, and optional state data.
def self.authorize_url(client_id, redirect_uri, scope, state=nil)
qs = "client_id=#{CGI.escape(client_id.to_s)}"
qs << "&redirect_uri=#{CGI.escape(redirect_uri.to_s)}"
qs << "&scope=#{CGI.escape(scope.to_s)}"
qs << "&state=#{CGI.escape(state.to_s)}" if state
"#{@@oauth_base_uri}?#{qs}"
end

At the very least, the method name should be authorization_url, at most construct_authorization_url. I'll opt for construct_authorization_url as the most descriptive.

On line 2 (see above ↑), the comment repeats argument list. It is redundant information that only leads to longer reading time and doesn't add anything to understanding. After removing that, I ended up with "Construct authorization URL for your application", and that essentially repeats method name construct_authorization_url. So, I've removed the comment altogeter.

Next, there's a lot of duplication with CGI.escape and #to_s. It turns out, there's a Hash#to_query in ActiveSupport that can build a HTTP query string. So I'm going to use that.

The result:

def self.construct_authorization_url(client_id, redirect_uri, scope,
state=nil)
params = {
client_id: client_id, redirect_uri: redirect_uri, scope: scope
}
params[:state] = state if state
"#{@@oauth_base_uri}?#{params.to_query}"
end

I would like to simplify it further, by treating arguments not as named attributes, but as an array, but that'd probably be an overkill.

Step 4.2: exchange_token

exchange_token does something OAuth-related. It constructs ULR parameters, makes a call to HTTP API and returns result (raising exception on failure). Take a look:

# Exchange a provided OAuth code for an OAuth access token, 'expires in'
# value, and refresh token.
def self.exchange_token(client_id, client_secret, redirect_uri, code)
body = "grant_type=authorization_code"
body << "&client_id=#{CGI.escape(client_id.to_s)}"
body << "&client_secret=#{CGI.escape(client_secret.to_s)}"
body << "&redirect_uri=#{CGI.escape(redirect_uri.to_s)}"
body << "&code=#{CGI.escape(code.to_s)}"
options = {:body => body}
response = HTTParty.post(@@oauth_token_uri, options)
if response.has_key? 'error' and response.has_key? 'error_description'
err = "Error exchanging code for access token: "
err << "#{response['error']} - #{response['error_description']}"
raise err
end
r = Hashie::Mash.new(response)
[r.access_token, r.expires_in, r.refresh_token]
end

Lines 4-8 (see above ↑) construct URL parameters, same thing as in the step 4.1, I'm going to use Hash#to_query here as well:

def self.exchange_token(client_id, client_secret, redirect_uri, code)
body = {
grant_type: 'authorization_code',
client_id: client_id,
client_secret: client_secret,
redirect_uri: redirect_uri,
code: code
}.to_query
options = {:body => body}
response = HTTParty.post(@@oauth_token_uri, options)
if response.has_key? 'error' and response.has_key? 'error_description'
err = "Error exchanging code for access token: "
err << "#{response['error']} - #{response['error_description']}"
raise err
end
r = Hashie::Mash.new(response)
[r.access_token, r.expires_in, r.refresh_token]
end

Step 4.2.2

Line 9 (see above ↑) is used to add options variable, used on the next line. I think it makes code harder to read. If I inline it into line 10, it won't be required to read it, which is a win:

def self.exchange_token(client_id, client_secret, redirect_uri, code)
body = {
grant_type: 'authorization_code',
client_id: client_id,
client_secret: client_secret,
redirect_uri: redirect_uri,
code: code
}.to_query
response = HTTParty.post(@@oauth_token_uri, body: body)
if response.has_key? 'error' and response.has_key? 'error_description'
err = "Error exchanging code for access token: "
err << "#{response['error']} - #{response['error_description']}"
raise err
end
r = Hashie::Mash.new(response)
[r.access_token, r.expires_in, r.refresh_token]
end

Step 4.2.3

It's still hard to read, so I'm going to extract some methods:

def self.exchange_token(client_id, client_secret, redirect_uri, code)
response = request_token(client_id, client_secret, redirect_uri, code)
fail_on_erroneous_response(response,
"Error exchanging code for access token")
r = Hashie::Mash.new(response)
[r.access_token, r.expires_in, r.refresh_token]
end
class << self
private
def fail_on_erroneous_response(response, message)
if response.has_key? 'error' and response.has_key? 'error_description'
err = "#{message}: "
err << "#{response['error']} - #{response['error_description']}"
raise err
end
end
def request_token(client_id, client_secret, redirect_uri, code)
body = {
grant_type: 'authorization_code',
client_id: client_id,
client_secret: client_secret,
redirect_uri: redirect_uri,
code: code
}.to_query
HTTParty.post(@@oauth_token_uri, body: body)
end
end

.request_token constructs URL and makes a HTTP call, returning a hash. Since I've already refactored the code that comprises it, there's nothing to change there.

.fail_on_erroneous_response accepts message argument and raises an exception on erroneous response. One reason to have message argument is to know the error message inside of .exchange_token as it helps to understand what's going on.

Step 4.2.4: fail_on_erroneous_response

At this point (lines 12-18 above ↑) we have two variables (message and err) for essentially the same thing - error message. err can be changed to full_error_message:

def fail_on_erroneous_response(response, message)
return unless response.has_key?('error') &&
response.has_key?('error_description')
full_error_message = "#{message}: " <<
"#{response['error']} - #{response['error_description']}"
raise full_error_message
end

I still find it hard to read, the code tells how it does its job, instead of expressing intent. Thus, I'll use extract method again:

def fail_on_erroneous_response(response, message)
return unless erroneous?(response)
raise format_response_error_message(response, message)
end
def erroneous?(response)
response.has_key?('error') && response.has_key?('error_description')
end
def format_response_error_message(response, message)
"#{message}: #{response['error']} - #{response['error_description']}"
end

Step 4.2.5

After refactoring extracted parts of .exchange_token, there's still that part where it returns the result (line 6):

def self.exchange_token(client_id, client_secret, redirect_uri, code)
response = request_token(client_id, client_secret, redirect_uri, code)
fail_on_erroneous_response(response,
"Error exchanging code for access token")
r = Hashie::Mash.new(response)
[r.access_token, r.expires_in, r.refresh_token]
end

I dislike to use such APIs because I have to remember what it returns before I need to use it. E.g.:

So, I replaced array with a data clump:

# Exchange a provided OAuth code for an OAuth access token, 'expires in'
# value, and refresh token.
def self.exchange_token(client_id, client_secret, redirect_uri, code)
response = request_token(client_id, client_secret, redirect_uri, code)
fail_on_erroneous_response(response,
"Error exchanging code for access token")
TokenResponse.from_hash(response)
end

class TokenResponse
ATTRS = %w[access_token expires_in refresh_token]
attr_reader *ATTRS
def self.from_hash(hash)
self.new *hash.values_at(*ATTRS)
end
def initialize(access_token, expires_in, refresh_token)
@access_token, @expires_in, @refresh_token =
access_token, expires_in, refresh_token
end
end

At this point I feel happy about .exchange_token.

Step 4.3: refresh_access_token

.refresh_access_token has the same ailments as .exchange_token:

# Refresh an OAuth access token, given an OAuth refresh token.
# Returns a new access token, 'expires in' value, and refresh token.
def self.refresh_access_token(refresh_token)
options = {
:body => "grant_type=refresh_token&refresh_token=#{CGI.escape(refresh_token)}" }
response = HTTParty.post(@@oauth_token_uri, options)
if response.has_key? 'error' and response.has_key? 'error_description'
err = "Error refreshing access token: "
err << "#{response['error']} - #{response['error_description']}"
raise err
end
r = Hashie::Mash.new(response)
[r.access_token, r.expires_in, r.refresh_token]
end

And is easy to fix in one go:

# Refresh an OAuth access token, given an OAuth refresh token.
# Returns a new access token, 'expires in' value, and refresh token.
def self.refresh_access_token(refresh_token)
response = request_access_token(refresh_token)
fail_on_erroneous_response(response, 'Error refreshing access token')
TokenResponse.from_hash response
end
class << self
...
def request_access_token(refresh_token)
body = {
grant_type: 'refresh_token',
refresh_token: refresh_token
}.to_query
HTTParty.post(@@oauth_token_uri, body: body)
end
end

There's a lot left to be refactored in Base class, and I will do it in part 2!

Happy hacking!

Fed up working on bad code? Here's a way out!

For people that that want to stop suffering from bad code I’ve made a FREE course

Web Analytics