‘Iacta alea est’
I was revisiting a small Ruby HTTP client wrapper that I wrote a while ago when my curiosity got the better of me and I attempted to see what it might look like with operations as data.
The wrapper itself came as a result of a propensity to try and visit problems at a more fundamental level instead of building upon layer and layer of abstraction at the library level. That propensity was borne out of my experiences with functional programming in Elm and the dawning realisation that much of what I learned there could be applied back to Ruby.
So instead of opting for one of the more popular high-level Ruby HTTP clients I instead wrote a small wrapper around net/http
, the client that comes as part of the Ruby standard library. And as far as caveats go, the use-case was relatively simple: making standard requests to a third-party REST-ful API.
Here is the client in all of 48 lines, implementing GET
, POST
, PUT
and DELETE
HTTP methods:
require "net/http"
class HTTPClient
def initialize(host, path_prefix, default_params)
@host = host
@path_prefix = path_prefix
@default_params = default_params
end
def get(path, query_params)
Net::HTTP.get(uri(path, query_params))
end
def post(path, query_params, body)
Net::HTTP.post(
uri(path, query_params),
body.to_json,
"Content-Type" => "application/json"
).body
end
def put(path, query_params, body)
uri = uri(path, query_params)
req = Net::HTTP::Put.new(uri, "Content-Type" => "application/json")
req.body = body.to_json
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request req
end.body
end
def delete(path, query_params={})
uri = uri(path, query_params)
req = Net::HTTP::Delete.new(uri, {})
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request req
end.body
end
def uri(path, query_params = {})
query_params.merge!(@default_params)
URI::HTTPS.build({
host: @host,
path: path.prepend(@path_prefix),
query: URI.encode_www_form(query_params)
})
end
end
Here’s how I would have instantiated the client:
HTTPClient.new("api.pipedrive.com", "/v1", {"api_token" => "<SECRET…>"})
And here is an example of how I used it to work against a third-party API in an API wrapper which takes the HTTP client above as a parameter on initialization:
class Pipedrive
def initialize(client)
@client = client
end
…
def create_organization(attrs)
jd client.post("/organizations", {}, attrs)
end
def delete_organization(id)
jd client.delete("/organizations/#{id}")
end
def update_organization(attrs)
id = attrs.delete("id")
jd client.put("/organizations/#{id}", {}, attrs)
end
def find_organization(id)
jd client.get("/oganizations/#{id}")
end
private
attr_reader :client
def jd(body)
response = JSON.parse(body)
return nil unless respons["success"]
response["data"]
end
end
I was fairly happy with this abstraction. Most of the HTTP details are hidden away in the client, the body parsing is a helper method, the methods take the most limited number of arguments necessary for the request, and there is a nice one-to-one mapping of a function in the API wrapper to an HTTP request.
Now, having seen how Elixir extracts operations as data in its modelling of an HTTP request, I wanted to give it a go in Ruby and compare the two approaches.
I came up with the following implementation, a thin wrapper around net/http
as before, but with all the request data lifted out into a Ruby Struct
.
# lib/http_client/request.rb
require "uri"
module HTTPClient
Request = Struct.new(
:body,
:headers,
:host,
:method,
:path,
:port,
:query,
:scheme,
keyword_init: true) do
def uri
scheme == :https ? https_uri : http_uri
end
def https_uri
URI::HTTPS.build(uri_params)
end
def http_uri
URI::HTTP.build(uri_params)
end
def uri_params
params = {
host: host,
path: path,
port: port
}.tap do |p|
p.merge!({
query: URI.encode_www_form(query)
}) if query
end
end
end
end
I have one convenience on this struct which is the generation of the URI from its constituent parts. Apart from that it is as identical a data structure to Finch.Request
as a Ruby struct and an Elixir struct can be.
Then there is the client itself:
# lib/http_client.rb
require "net/http"
require_relative "./http_client/request"
module HTTPClient
def self.request(req)
case req.method
when :get
get(req)
when :post
post(req)
when :put
put(req)
when :delete
delete(req)
else
puts "nonsense"
end
end
def self.delete(req)
request = Net::HTTP::Delete.new(req.uri, req.headers || {})
process_request(req, request)
end
def self.get(req)
Net::HTTP.get(req.uri)
end
def self.post(req)
Net::HTTP.post(req.uri, req.body, req.headers)
end
def self.put(req)
request = Net::HTTP::Put.new(req.uri, req.headers || {})
request.body = req.body
process_request(req, request)
end
def self.process_request(req, net_http_request)
Net::HTTP.start(req.host, req.port, use_ssl: req.scheme == :https) do |http|
http.request net_http_request
end
end
end
The client certainly feels tighter although that’s maybe down to moving the URI generation out to the HTTPClient::Request
struct. The other element conspicuous by its absence is any sort of configuration. The client is a module, not a struct. As such there are no default values stored at this level.
This is intentional, by separating out all of the request data into the struct, any default settings can inhabit a layer between the struct and the client. The default settings can essentially be merged with the request just before it gets passed on to the client.
I think this is a good example of the neat separation of concerns that functional programming forces you into. It ends up being slightly more verbose, there is an extra step, but the steps themselves are more comprehensible and more closely suited to a particular responsibility. The HTTPClient
module is not bothered with configuration concerns, neither is the request.
Here’s what that extra layer could look like:
def with_defaults(req)
req.tap do |r|
r.headers = r.headers.merge({"Content-Type" => "application/json"})
r.query = r.query.merge({"api_token" => "<SECRET…"})
r.scheme = :https
r.host = "api.pipedrive.com"
end
end
HTTPClient.post(with_defaults(req))
Or better, we could attach the defaults before creating the request struct. If we stuck to hashes with the same keys then it is simply a matter of deep merging.
This could live in the API wrapper where it likely belongs. The API wrapper would therefore also look different:
class Pipedrive
def initialize(req_params)
@default_req_params = req_params
end
…
def create_organization(attrs)
request({
method: :post,
path: "/organizations",
body: attrs.to_json
})
end
def delete_organization(id)
request({
method: :delete,
path: "/organizations/#{id}"
})
end
def update_organization(attrs)
id = attrs.delete("id")
request({
method: :put,
path: "/organizations/#{id}",
body: attrs.to_json
})
end
def find_organization(id)
request({
method: :get,
path: "/organizations/#{id}"
})
end
private
def request(params)
req = HTTPClient::Request.new(with_defaults(params))
jd HTTPClient.request(req)
end
def with_defaults(params)
params.deep_merge(@default_req_params, params)
end
def jd(body)
response = JSON.parse(body)
return nil unless response["success"]
response["data"]
end
end
Although I was worried that having to call the module HTTPClient::Request.new
each time would end up making the code a little ugly, the uniformity of the interface means we can put that all behind the scenes. What we are left with in each method is a neat and tidy, declarative specification of the HTTP request:
request({
method: :put,
path: "/organizations/#{id}",
body: attrs.to_json
})
And I must admit, I really like it. I’ve really surprised myself there. Tomorrow I’ll review it for you in more detail.
—Tuesday 26th January 2021.