How I Learned To Stop Worrying And Love APIs
Time To Crack the API Walnut
Comprehending what an API is in theory is quite easy for anyone who understands how to write shell scripts or code at the most basic level, but understanding how to make API calls to a variety of endpoints and parse the responses takes time and effort. I already had a practical understanding of simple GET requests, but I decided over the Christmas break to master the fundamentals of APIs.
My learning-by-doing approach was as follows:
- Create a wrapper Module for Ruby’s
http/netcalledApiUtil. - Write unit tests in RSpec for a variety of public, open API endpoints.
- Continually refine and extend
ApiUtiluntil it could reliably work with all the endpoints.
To this end, I cut my teeth on JSON Placeholder, then refined my base driver code on Httpbin, then toyed with a selection of endpoints from HTTPCat then finished with the most recognisable Bit.ly endpoints for shortening a link and expanding a bitlink.
By the end of my project, I had a tidy API wrapper method ApiUtil#api_request which does the following:
- Accept as parameters all the information needed to make the appropriate API call to a given endpoint.
- Create and send an API request object, e.g.
Net::HTTP::GETorNet::HTTP::POST - Receive the response as an
HTTP::OKobject (if successful). - Return the parsed response as a
Cpc::Util::ApiResponseclass instance.
Why Not Just Use An Existing Library?
At work, where time is of the essence, I would have spent the equivalent time in familiarising myself with a third-party library, but seeing as all the gems out there are wrappers for net/http, and I was on holiday, I could afford to advance my learning by working directly with pure API calls and API responses.
ApiUtil#api_request
module Cpc
module Util
module ApiUtil
def api_request(type_str, args_hsh, encoding_str)
end
end
end
end
This method takes three parameters:
type_str=> the type of request, e.g. GET or POST, as a String.args_hsh=> URL, request headers, and request body, all packaged into a Hash. (Nested key-value pairs)encoding_str=> Encoding to apply to the response body, as a String.
The method then constructs the request:
- Converts the URL into a URI,
- Determines whether to use SSL,
- Creates a new request object – GET, POST, etc.
- Adds request headers, if any included,
- Adds the request body, if any included,
The method then sends the request and receives the response from the server:
- If the response is 302 (Redirect), a subsequent API call is constructed and sent to the redirect endpoint.
- If the response is not 302, the response
The final HTTP::OK object is passed to a instance of my custom Class Cpc::Util::ApiResponse, which parses the response object and makes available as readable attributes the API response code, response body, and response headers:
module Cpc
module Util
class ApiResponse
include Cpc::Util::StringUtil
include Cpc::Util::CollectionUtil
attr_reader :code, :body, :headers
def initialize(res_obj, encoding_str)
@res = res_obj
@code = res_obj.code.to_i
@body = parse_response_body(encoding_str)
@headers = collect_headers
end
def parse_response_body(encoding_str)
# Parses @res.body as JSON, HTML, XML, or simply returns as is.
# Forces encoding if conditions are met
end
def collect_headers
headers_hsh = Hash.new
@res.to_hash.each { |k, v| headers_hsh[k] = v.first }
headers_hsh
end
end
end
end
This light-weight object is more comfortable to work with than a simple Hash, and includes a couple of methods for nested information which it would not be worthwhile to add as readable attributes:
def temporary_headers
@body['headers']
end
def method
@body['method']
end
def origin
@body['origin']
end
Thus res, an initialized Class instance, would have the following
res.code=> the API response code, converted to an integer.res.body=> key-value pairs, image, text, etc.res.headers=> usually key-value pairs.
In most cases, the response body is UTF-8-encoded String, but the method #parse_response_body allows for others such as Base64 for images.
The response body is returned either as a String or it parsed:
- If valid JSON, then
JSON#parsereturns a JSON object. - If valid HTML, then
Nokogiri::HTMLreturns an HTML object. - If valid XML, then
Nokogiri::XMLreturn an XML object. - Else, the response body is returned as it is. (If an image is expected, the method that invokes
ApiUtil#api_requestcan save the body to an image file, e.g. JPG or PNG)
API Classes
At the time of writing, there are four Classes in the Module Cpc::Api that utilise ApiUtil:
In each class, there is an instance method for every API endpoint. Because ApiUtil#api_request takes care of the entire API call from request to response, all that is required to invoke it are the following parameters:
- URL
- Request headers
- Request body
These parameters are bundled into a Hash – args_hsh – which is the most important parameter of the ApiUtil#api_request method. To make api_request a little easier to work with, each type of API call has its own wrapper method:
api_get_request=>api_request('get', args_hsh, encoding)api_post_request=>api_request('post', args_hsh, encoding)api_put_request=>api_request('put', args_hsh, encoding)api_delete_request=>api_request('delete', args_hsh, encoding)api_patch_request=>api_request('patch', args_hsh, encoding)
Thus, any method that invokes api_request is constructed as follows:
- Declare a Hash that contains at least the URL.
- Pass the Hash, and encoding if required, to the appropriate
api_requestmethod.
At the end of the process ApiUtil#api_request instantiates and returns a Cpc::Util::ApiResponse object, which has the following instance variables:
@code@body@headers
Example: Naked GET Request
Httpbin provides an endpoint for a simple GET request with neither headers nor a body: https://httpbin.org/get
The Class Cpc::Api::Httpbin initializes with an instance variable @host:
def initialize(host_url)
if host_url.nil?
@host = 'https://httpbin.org'
else
@host = host_url
end
end
Thus, a method to hit the GET endpoint:
def naked_get
args_hsh = { url: { host: @host, path: 'get' } }
api_get_request(args_hsh)
end
ApiUtil#api_get_request joins the values of the nested Hash { host: @host, path: 'get' } into a URL String and then passes that String to URI#parse; the URI is added to a new GET request; and ultimately, api_request returns a Cpc::Util::ApiResponse object:
@body=
{"args"=>{},
"headers"=>{"Accept"=>"*/*", "Accept-Encoding"=>"gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "Connection"=>"close", "Host"=>"0.0.0.0", "User-Agent"=>"Ruby"},
"origin"=>"172.17.0.1",
"url"=>"http://0.0.0.0/get"},
@code=200,
@headers=
{"server"=>"gunicorn/19.9.0",
"date"=>"Tue, 07 Jan 2020 21:19:32 GMT",
"connection"=>"close",
"content-type"=>"application/json",
"content-length"=>"263",
"access-control-allow-origin"=>"*",
"access-control-allow-credentials"=>"true"}
Example: POST with URL Parameters
def post_response_headers(str)
args_hsh = {
url: { host: @host, path: 'response-headers' },
url_params: { "freeform": str }
}
api_post_request(args_hsh)
end
ApiUtil#