My favourite HTTP client
It’s 2019. I’m writing code that needs to communicate with other APIs all the time. The code I post below reflects a snapshot of my current template API client. If I need to commuicate with a third-party in Ruby, I’m using this class.
require "ostruct"
class MyApiClient
class Error < StandardError; end
def initialize(configuration=nil, stubs=nil)
@configuration = configuration || Rails.application.config_for(:my_api)
@connection = Faraday.new(url: @configuration[:host], headers: headers) do |conn|
conn.request :json
conn.response :json, parser_options: { object_class: OpenStruct }
conn.response :raise_error
conn.response :logger, Rails.logger, bodies: true if @configuration[:debug]
stubs ? conn.adapter(:test, stubs) : conn.adapter(Faraday.default_adapter)
end
end
%i[get post patch put delete].each do |verb|
define_method(:"raw_#{verb}") do |*args|
@connection.public_send(verb, *args)
rescue Faraday::Error
raise Error
end
define_method(verb) do |*args|
public_send("raw_#{verb}", *args).body
end
end
def headers
{ user_agent: user_agent } # ... other default headers here
end
def user_agent
@configuration.fetch(:user_agent, "My Application #{Rails.application.config.version}")
end
end
Let’s deconstruct this.
- We require “ostruct” to get the
OpenStruct
class.OpenStruct
is a handy little class in the Ruby standard library that accepts a hash, and allows attributes to be accessed either via hash keys (my_object[:attribute]
), or via method calls (my_object.attribute
). - Make the class
MyApiClient
. Note we’re not subclassing anything here. This class uses a bit of stuff from Rails, but it’s all optional. - Define our own error class, a subclass of
StandardError
. We’ll use this class further down to wrap all exceptions coming from our HTTP library. Wrapping exceptions is beneficial since it allows consumers of this API client to not need to know too much about how the request is being made - just that something went wrong. If something goes wrong here, aMyApiClient::Error
will be raised. The consumer can handle this with an error message or retry and/or inspect thecause
attribute of the error to access the specific error from the library if more context is required. - Start our
initialize
method. This method accepts two arguments -configuration
andstubs
. We’ll get into what these do in the next couple of points. - Set up
@configuration
. This defaults to whatever is passed in - the API client just expects something that has hash-style accessors - so aHash
would do the job here, but also anActiveRecord
model, anOpenStruct
, or anything else that implements[](key)
. If nothing is passed in, we fall back to getting Rails to fetch our configuration usingconfig_for
. I have blogged before aboutconfig_for
, so won’t go into detail - basically Rails looks for a YAML file inconfig/YOUR KEY.yml
- soconfig/my_api.yml
in this case, parses it to a Hash, and then grabs whatever values are under the key named byRails.environment
- sodevelopment
,test
,production
etc. It’ll also run this file through ERB when it reads it, so you can reference environment variables or any other config service using<%= %>
tags. - Set up the base HTTP connection. This is an HTTP API client, not an HTTP API
library, so we want to lean on others’ hard work here. You can actually use any
HTTP library you’d like here - as you’ll see further down, all that our class really needs of
@connection
are methods representing the HTTP verbs -get
,post
, etc. Some libraries even just have arequest(verb, ...args)
method you could use instead. It really doesn’t matter too much as long as your helper methods we’re about to define know what to expect back from whatever library you’re using. In this case, I’m using the Faraday gem, with middleware. The set up I have with Faraday is useful, but not super specific to this client class, so I’ve talked more about it in the Faraday section below. - Next we define some methods - two methods for each HTTP verb. The first method is
named
raw_#{verb}
-raw_get
,raw_post
, and so on. The purpose of this method is to take arguments for a request that the HTTP library is expecting, and make a request. It should return the raw response object that the library returns. In this method, we rescue errors bubbling up from our HTTP library - in this case,Faraday::Error
, and re-raise our own error. Callingraise
inside arescue
block with a new exception class like this will automatically assign the original error to thecause
attribute of the re-raised error. The second method we define is just named after the verb -get
,post
, etc. This method is intended as a friendlier version of theraw_request
method, and the idea behind this one is to provide a shortcut to just getting the response body data - 99% of the time, this is what you want, and so long as your HTTP library can raise errors when it runs into bad HTTP status codes (e.g. 400..600), you don’t need to worry about checking the response status - just handling any errors. For the HTTP library used here,faraday
, we have theraise_error
middleware making sure that errors are raised when something goes wrong, and thejson
response parser that will turn our response body into an object. - We’re nearly done - just a couple of configuration methods to go.
headers
should return the default headers to be applied to all requests. In this case, we just add aUser-Agent
header. It’s courtesy when consuming an API to make sure that your requests are identifiable, and theUser-Agent
header is perfect for this. You can put any other headers you’d like here in, such asAuthorization
,X-Api_Key
, etc. - The
user_agent
method just builds a UA string for us to use - here, we’re using the common name of our application, and the version of our application. This will return something like “My Application abc123”, which allows both the name and release of our application to be identified, and if necessary filtered or rate limited. Without adding a user-agent, the HTTP library will usually use it’s own name as the user agent string, which means that all of your requests will be indistingushable from all the other “faraday”, “HTTParty”, “curl”, “wget”, etc. requests that others are making.
And that’s it! Testing is also pretty simple. I usually prefer integration testing
something like this by mocking an HTTP request/response - usually the HTTP library
will support something like this. You can also test your dynamic method definitions
by calling them and asserting that the same method with expected args is called on
the @connection
, with the default headers mixed in.
Faraday
You’ll notice above that Faraday makes up a fair amount of the meat of functionality of this class. I wanted to break it into it’s own section because, as I mentioned above, it doesn’t really matter what HTTP library is used, so long as it can be passed data to make a request with, and passes some kind of response object back. Examples of other HTTP libraries you might consider instead of Faraday are:
-
excon
- a bit more bare metal, but a much smaller dependency - this would be suitable if you had a moderately complex HTTP endpoint to communicate with from a gem where you maybe didn’t want to have a dependency as large as Faraday. -
HTTParty
- quite a flexible library but sometimes a bit too abstract for my liking. It also has an annoying post-install message wheneverbundle install
is run - including if it’s depended on by another gem. The README for HTTParty has it’s own example of how to make a super-slim API client class, so if you’re looking for something specific to HTTParty, be sure to check that out. -
Net::HTTP
. Oh-so-tempting since it’s part of the Ruby standard library, but it really is a low-level API.Net::HTTP
may be worth considering if you are the author of a gem and don’t want to add extra dependencies, but otherwise it’s probably best to use a library to avoid code that is perhaps more verbose than it needs to be.
Faraday is modelled on rack, which is the de-facto interface between HTTP requests/responses, and your Ruby server. You could think of Faraday as the ‘frontend’ version of Rack.
It has a similar system of almost immediately bundling the request/response into an ‘env’ (environment) object, and then fulfilling the request and transforming the response by applying a middleware pipeline to it.
The actual out-of-the-box behaviour of Faraday is quite capable of making an HTTP or
HTTPS request, complete with params, headers, and all of the other stuff that makes
up the core of HTTP. To really unlock some neat behaviour though, it’s worth checking
out the middleware that can be applied to your Faraday connection object. Middleware
have been split out of the main Faraday project, so you only need to have that extra
gem dependency if you need it. The gem is called
faraday_middleware
.
The middleware I use in my API client is relatively small. I’ll step through each one and describe what I use it for/what it does:
-
conn.request :json
- tells FaradayMiddleware::EncodeJson to automatically transform any params or request body I pass in to JSON. This means that I can pass in a whole big hash (or in fact anything that responds toto_json
), and Faraday will automatically transform it into JSON before sending the request. -
conn.response :json, parser_options: { object_class: OpenStruct }
- tells Faraday to automatically transform the response body from a string, back into JSON (obviously this requires the response actually be valid JSON!). I am passing some specialparser_options
here to tell JSON to decode usingOpenStruct
as the object class. Normally,JSON.parse
will return a Hash, which is fine, but means that all the attributes need to be accessed using[]
with String keys. UsingOpenStruct
as the object class means that (as I mentioned above), attributes can be accessed using hash-key lookup syntax, or method syntax. I’ve also written a blog post that describes this technique with a few examples at https://www.joshmcarthur.com/til/2018/12/03/ruby-deserialize-json-to-an-openstruct.html. -
conn.response :raise_error
- this middleware is actually part of the main Faraday project, so you don’t need the middleware gem for this one. It inspects the HTTP status code returned in the request, and will raise a variant ofFaraday::Error
if the request did not succeed. These variants can be specific for common statuses, likeFaraday::BadRequestError
orFaraday::ResourceNotFound
, or a bit more generic, likeFaraday::ClientError
andFaraday::ServerError
. The rresponse status, headers, and body are attached to the error for later inspection. In the API client, we’re using this middleware to make sure thatFaraday::Errors
are raised when a HTTP request fails - we’re then rescuing the error, wrapping it in our own error class, and re-raising it. -
conn.response :logger, Rails.logger, bodies: true if @configuration[:debug]
. By default, Faraday won’t really log much that is useful. This is by design, since logging a full request/response takes up a number of log lines. This middleware is conditionally added to the connection if the configuration we pass in (which, remember, can either come from a hash passed to the API client, or by included in ourconfig/api_client.yml
asdebug: true
) includes a:debug
key that is truthy. Ifdebug
mode is set, we direct the Faraday logging middleware at our Rails.logger (you could direct it to it’s own log file or any otherLogger
if you wanted to, butRails.logger
means that all our app logs go into a single stream), and also tell it to log the request body with thebodies: true
option. Without this option, Faraday will only log the request URL and some minimal response info, which isn’t as useful for debugging as seeing the full request/response. -
conn.adapter
- set either to:test
, orFaraday.default_adapter
. This setting is conditional on whetherstubs
have been passed in to the API client. If they have, we assume that we’re testing the API, so we tell Faraday to use a fake adapter named ‘test’. This adapter will look up a request in thestubs
object when the client is called, and if a stub exists that matches the request (matching on path and/or params and/or headers), it will return the stubbed response. If stubs have not been passed in, we’re operating in ‘real’ mode, and set the adapter toFaraday.default_adapter
. This defaults tonet/http
, but there’s all sorts of adapters you can use instead.For more information on testing and using adapters with Faraday, you’ll find both the testing guide and the adapters guide useful.
That’s it. Hopefully this has been a useful and interesting deep dive into a nice understandable and configurable HTTP API client. If you’ve spotted any mistakes or points that need clarifying, please feel free to contribute a patch to my website repo!