Dan Knox

Thoughts. Comments. Projects.

Using Ruby's Native Net::HTTP Library

Dan Knox - Dan Knox

In this post, I am going to step through constructing a basic wrapper class for simplifying interaction with the Net::HTTP library. I came across a few answers on stack overflow recently that seemed to give the impression that working with Net::HTTP is more difficult than it needs to be.

While there are many great ruby HTTP client gems out there, the native library can be useful when you need to make a few simple HTTP requests. Try giving it a shot first before reaching for a heavier weight solution. If nothing else, you will have a better understanding of what is happening behind the scenes of your favorite HTTP client gem.

The Basics

If you are already familiar with basic usage of the Net::HTTP library, you may want to skip ahead to the good stuff.

After browsing the documentation it is clear that there are many different ways to interact with the native library. For our purposes we are going to go with what is, in my opinion, one of the most straightforward but least well documented.

First, we will create a new instance of the Net::HTTP class with a reference to our main API endpoint. The endpoint is the full address of the API server without any of the paths for the specific requests. In our case, we will use the new API based temporary email service that I am building. It is currently located at http://dev.threedotloft.com.

require "net/http"
require "uri"

uri = URI.parse("http://api.random.com")
http = Net::HTTP.new(uri.host, uri.port)

We now have our http instance that we can use to perform requests. A basic request is constructed using the appropriate class for the HTTP request method that you wish to perform. For an HTTP GET, you would use Net::HTTP::Get.

# Continuing our example from above

request = Net::HTTP::Get.new("/search?question=somequestion")
response = http.request(request)

response.code
=> "200"
response.body
=> # Raw response body would go here needing to be parsed

If you don't care for checking the response code, the response object's class happens to reflect the status of the response as well.

response.code
=> "200"
response.class
=> Net::HTTPSuccess

case response
when HTTPSuccess
  response.body
when HTTPRedirect
  follow_redirect(response) # you would need to implement this method
else
  raise StandardError, "Something went wrong :("
end

I personally find it more succinct to operate on the response code.

case response.code.to_i
when 200 || 201
  p [:success]
when (400..499)
  p [:bad_request]
when (500..599)
  p [:server_problems]
end

Now that we are familiar with the basics of interacting with the library, we can begin packaging all of this up into a nice class. In the interest of brevity, I am going to leave out a few details such as error handling. I am also not including the tests behind this class, although I may follow up with another post that covers building out this class in a test driven manner and adding more advanced functionality.

Creating Our Connection Class

The first thing we need to do is store our http instance upon initialization so we can use it later to perform requests.

require "net/http"
require "uri"

class Connection

  ENDPOINT = "http://dev.threedotloft.com"

  def initialize(endpoint = ENDPOINT)
    uri = URI.parse(endpoint)
    @http = Net::HTTP.new(uri.host, uri.port)
  end

end

Since we know the address of the API endpoint that we are targeting, we use that as the default option. However, we allow for overriding that at runtime to provide a little more flexibility.

The next thing we need is a way to perform the actual requests. Our first shot may look something like this.

# Inside the Connection class
def get(path, params)
  full_path = encode_path_params(path, params)
  request = Net::HTTP::Get.new(full_path)
  @http.request(request)
end

def post(path, params)
  request = Net::HTTP::Post.new(path)
  request.set_form_data(params)
  @http.request(request)
end

def put(path, params)
  request = Net::HTTP::Put.new(path)
  request.set_form_data(params)
  @http.request(request)
end

private

def encode_path_params(path, params)
  encoded = URI.encode_www_form(params)
  [path, encoded].join("?")
end

So far we have only implemented three of our HTTP verbs and we are already starting to see a lot of duplication. Lets factor the bulk of that duplication into a common #request method. This refactoring will give us a central place to make changes in the event that we want to implement things like response body deserialization.

Since each request type needs a different class instantiated, we will include a hash to map the HTTP verbs to their respective request classes.

class Connection

  VERB_MAP = {
    :get    => Net::HTTP::Get,
    :post   => Net::HTTP::Post,
    :put    => Net::HTTP::Put,
    :delete => Net::HTTP::Delete
  }

  # ENDPOINT declaration and initialization goes here.
  
  private
  
  def request(method, path, params)
    case method
    when :get
      full_path = encode_path_params(path, params)
      request = VERB_MAP[method].new(full_path)
    else
      request = VERB_MAP[method].new(path)
      request.set_form_data(params)
    end

    @http.request(request)
  end

Now we can go back and refactor our original implementation to use this common request method and implement the remaining HTTP verbs while we are at it.

def get(path, params)
  request :get, path, params
end

def post(path, params)
  request :post, path, params
end

def put(path, params)
  request :put, path, params
end

def delete(path, params)
  request :delete, path, params
end

Since the TmpMail API only makes use of these four common verbs, we are finished with our request logic. If we need to add more later it is now a trivial matter of adding the correct class to the VERB_MAP and implementing any special parameter handling within the request method.

We are almost finished. Our class can now perform the most common http actions, exposing a simple and intuitive public API. However, our API endpoint returns JSON response bodies and it would be a pain to have to deserialize the response each time we made a request so lets go ahead and add deserialization to the Connection class.

As with anything in ruby, there are many different ways we could go about this. I am going to add a #request_json method that calls our original #request method, parses the response body and returns the deserialized request.

require "ostruct"
require "json"

# Still in the Connection class

private

def request_json(method, path, params)
  response = request(method, path, params)
  body = JSON.parse(response.body)

  OpenStruct.new(:code => response.code, :body => body)
rescue JSON::ParserError
  response
end

In a real application it would typically not be a good idea to silently swallow the JSON parser error. It would be far better to define your own custom exception class and raise that. Just make sure to clearly document the exceptions your method may raise and why.

Since I prefer to also return the status code of the response, I chose to package the code and deserialized body together using ruby's OpenStruct library. For those of you not familiar with the OpenStruct library, I recommend checking out the documentation. It provides a quick way to bundle together attributes within an object. If the requirements grow and you need things like dynamic content type deserialization, I would likely factor this out into a custom Response class.

Now we will go ahead and update our public verb methods to use this new request_json method. Let's take a look at the final product.

require "net/http"
require "uri"
require "ostruct"
require "json"

class Connection

  ENDPOINT = "http://api.random.com"

  VERB_MAP = {
    :get    => Net::HTTP::Get,
    :post   => Net::HTTP::Post,
    :put    => Net::HTTP::Put,
    :delete => Net::HTTP::Delete
  }

  def initialize(endpoint = ENDPOINT)
    uri = URI.parse(endpoint)
    @http = Net::HTTP.new(uri.host, uri.port)
  end

  def get(path, params)
    request_json :get, path, params
  end

  def post(path, params)
    request_json :post, path, params
  end

  def put(path, params)
    request_json :put, path, params
  end

  def delete(path, params)
    request_json :delete, path, params
  end

  private

  def request_json(method, path, params)
    response = request(method, path, params)
    body = JSON.parse(response.body)

    OpenStruct.new(:code => response.code, :body => body)
  rescue JSON::ParserError
    response
  end

  def request(method, path, params = {})
    case method
    when :get
      full_path = encode_path_params(path, params)
      request = VERB_MAP[method.to_sym].new(full_path)
    else
      request = VERB_MAP[method.to_sym].new(path)
      request.set_form_data(params)
    end

    @http.request(request)
  end

  def encode_path_params(path, params)
    encoded = URI.encode_www_form(params)
    [path, encoded].join("?")
  end

end

There we have it. Our finished HTTP client. Lets take it out for a quick spin and check a few emails.

> connection = Connection.new
 => #<Connection:0x007ffac20f5270 @http=#<Net::HTTP dev.threedotloft.com:80 open=false>>

> res = connection.post("/account", {email: 'dknox@threedotloft.com',
>                                    password: 'some temp pass'})
> res.code
 => 200
> res.body
 => {
  "email"=>"dknox@threedotloft.com",
  "auth_token"=>"SnpuWVlPYmx2R052amVrQnd6RVV3TFtYcg=="
}

I'm going to store the authentication token in a variable for use in the next few API calls.

> auth_token = res.body["auth_token"]

Next we are going to request a list of the available domains on the server and then join one so we can start receiving emails.

> res = connection.get("/domains",{token: auth_token})
> res.body
 => 
[
  {
    "name"=>"dev.threedotloft.com"
  }, 
  {
    "name"=>"tmpmail.threedotloft.com"
  }
] 

# Join the domain first
> res = connection.post("/domains/join",{token: auth_token,
                                         name: 'dev.threedotloft.com'})

 => #<OpenStruct code="200", body=[{"name"=>"dev.threedotloft.com"}]>

# Claim your email second
> res = connection.post("/inboxes/claim",{token: auth_token,
                                          name: 'dknox',
                                          domain: 'dev.threedotloft.com'})
> res.body
 => [
 {
   "name"=>"dank",
   "message_count"=>0,
   "domain"=>"dev.threedotloft.com",
   "full_address"=>"dank@dev.threedotloft.com"
 },
 {
   "name"=>"dknox",
   "message_count"=>0,
   "domain"=>"dev.threedotloft.com",
   "full_address"=>"dknox@dev.threedotloft.com"
 }
]

Based on the output from the last call, you can see that I currently own two email addresses. The first is dank@dev.threedotloft.com and the second is dknox@dev.threedotloft.com. They both currently have zero messages in their Inbox. I am going to send a test email out and see if it shows up in my email service.

You will have to imagine the sending of the email as it is currently taking place off-camera...

Alright, the test email has been sent. Lets call the API again to see if it has arrived.

> res = connection.get("/messages", {token: auth_token,
                                     inbox: 'dknox',
                                     domain: 'dev.threedotloft.com'})
> res.body.count
 => 1
> res.body
 =>
  [{
    "id"=>"512ea4661d41c8adf7000001", 
    "date"=>"2013-02-27T16:27:51-08:00", 
    "subject"=>"Testing Email API", 
    "to"=>["dknox@dev.threedotloft.com"], 
    "from"=>["dknox@threedotloft.com"], 
    "reply_to"=>nil, 
    "cc_addrs"=>[], "bcc_addrs"=>[], 
    "headers"=>{}, 
    "raw"=> **Omitted Due To Length**
  }]

Awesome, it looks like my test email arrived successfully, thus concluding this demonstration.

Feel free to use this code yourself. As mentioned above, this basic example is mostly devoid of error handling. If you choose to use it you should probably modify it a bit first.