Consuming a REST API with the Ruby standard library

Making use of REST APIs is a commonplace task for many a software developer. When working with Ruby there are quite a few RubyGems that you can use to aid you in this endeavour. Faraday, httpclient and HTTParty are examples just to name a few. If you however do not want to add dependencies to your project you can also make us of Ruby’s standard library, which is what I will be going through in this article.

The example REST service

For the purpose of this article I have written a basic example REST service (which does have a few other dependencies) that you can run locally if you want to follow along with this article, you can get the code from GitHub here. I used Ruby 2.6.6 for this article, you can likely follow along using other versions but your mileage may vary.

The example service manages a table of messages which it stores in a SQLite database on disk. It offers the following endpoints:

MethodRouteDescription
GET/Test endpoint. This will return a greeting.
GET/messagesRetrieve all messages.
POST/messagesSave a new message.
DELETE/messages/:idDelete the message with the given id. This endpoint requires that you authenticate yourself.

To run the service follow the instructions in the README under “Running the server” at the end of which you can start it by running “ruby server.rb”.

cor@nomac in ~/code/rest_example on git master at 13:38:50 CEST 
➜  ruby server.rb            
[2020-09-13 13:38:55] INFO  WEBrick 1.4.2
[2020-09-13 13:38:55] INFO  ruby 2.6.6 (2020-03-31) [x86_64-linux]
== Sinatra (v2.1.0) has taken the stage on 4567 for development with backup from WEBrick
[2020-09-13 13:38:55] INFO  WEBrick::HTTPServer#start: pid=6247 port=4567

When you see the above on your screen the service runs and is available on http://localhost:4567.

Trying the API with cURL

Before writing code against an API I usually like to poke at it a bit first, a tool I often use for this is cURL. In case you are not familiar with cURL, it is a command line utility (and library used within a lot of other software) to transfer data with URLs. That of course includes making HTTP and HTTPS requests.

If you are running Linux or Mac OS then chances are pretty high that cURL is already installed on your system, if you do not have cURL you can either install it through your system’s package manager or obtain it from the download page on the cURL website.

Now let’s run cURL against the example REST service:

cor@nomac in ~/code/rest_example on git master at 13:39:34 CEST 
➜  curl -v http://localhost:4567/
*   Trying 127.0.0.1:4567...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 4567 (#0)
> GET / HTTP/1.1
> Host: localhost:4567
> User-Agent: curl/7.68.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK 
< Content-Type: application/json
< Content-Length: 26
< X-Content-Type-Options: nosniff
< Server: WEBrick/1.4.2 (Ruby/2.6.6/2020-03-31)
< Date: Sun, 13 Sep 2020 11:39:35 GMT
< Connection: Keep-Alive
< 
* Connection #0 to host localhost left intact
{"greeting":"Hello World"}%

The -v option that I added above tells cURL to run in verbose mode, this allows us to see the request and response HTTP headers as well as the response body. Calling the service’s root returns a JSON message with an element “greeting” with the value “Hello World”.

Now let’s try to save a new message:

cor@nomac in ~/code/rest_example on git master at 13:41:00 CEST 
➜  curl -v -H 'Content-Type: application/json' -d '{"message": "This message was sent using cURL"}' http://localhost:4567/messages
*   Trying 127.0.0.1:4567...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 4567 (#0)
> POST /messages HTTP/1.1
> Host: localhost:4567
> User-Agent: curl/7.68.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 47
> 
* upload completely sent off: 47 out of 47 bytes
* Mark bundle as not supporting multiuse
< HTTP/1.1 201 Created 
< Content-Type: application/json
< Content-Length: 53
< X-Content-Type-Options: nosniff
< Server: WEBrick/1.4.2 (Ruby/2.6.6/2020-03-31)
< Date: Sun, 13 Sep 2020 11:41:08 GMT
< Connection: Keep-Alive
< 
* Connection #0 to host localhost left intact
{"id":1,"message":"This message was sent using cURL"}% 

In the above command I posted a JSON message, the service returned HTTP code 201, which indicates that it saved the message. In the response body the message we sent is echoed back to us with the id it has been given by the service.

Now we are going to verify the service actually stored the message by trying the GET /messages endpoint:

cor@nomac in ~/code/rest_example on git master at 13:41:08 CEST 
➜  curl -v http://localhost:4567/messages
*   Trying 127.0.0.1:4567...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 4567 (#0)
> GET /messages HTTP/1.1
> Host: localhost:4567
> User-Agent: curl/7.68.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK 
< Content-Type: application/json
< Content-Length: 68
< X-Content-Type-Options: nosniff
< Server: WEBrick/1.4.2 (Ruby/2.6.6/2020-03-31)
< Date: Sun, 13 Sep 2020 11:41:56 GMT
< Connection: Keep-Alive
< 
* Connection #0 to host localhost left intact
{"messages":[{"id":1,"message":"This message was sent using cURL"}]}%

The last thing to try is the DELETE /messages/:id endpoint to delete the message we created.

By default cURL will assume a request without a body is a GET request and a request with a body is a POST request. You can specify the request type with the -X switch like below (to in this case send a DELETE request):

cor@nomac in ~/code/rest_example on git master at 13:41:56 CEST 
➜  curl -v -X DELETE http://localhost:4567/messages/1
*   Trying 127.0.0.1:4567...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 4567 (#0)
> DELETE /messages/1 HTTP/1.1
> Host: localhost:4567
> User-Agent: curl/7.68.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 401 Unauthorized 
< WWW-Authenticate: Basic realm="Restricted Area"
< Content-Type: text/html;charset=utf-8
< Content-Length: 0
< X-Xss-Protection: 1; mode=block
< X-Content-Type-Options: nosniff
< X-Frame-Options: SAMEORIGIN
< Server: WEBrick/1.4.2 (Ruby/2.6.6/2020-03-31)
< Date: Sun, 13 Sep 2020 11:42:18 GMT
< Connection: Keep-Alive
< 
* Connection #0 to host localhost left intact

As you can see in the response above the DELETE endpoint requires authorization. The username (hardcoded in the service in this case) is “hello” and the password is “world”:

cor@nomac in ~/code/rest_example on git master at 13:42:18 CEST 
➜  curl -v -X DELETE --user hello:world http://localhost:4567/messages/1
*   Trying 127.0.0.1:4567...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 4567 (#0)
* Server auth using Basic with user 'hello'
> DELETE /messages/1 HTTP/1.1
> Host: localhost:4567
> Authorization: Basic aGVsbG86d29ybGQ=
> User-Agent: curl/7.68.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 204 No Content 
< X-Content-Type-Options: nosniff
< Server: WEBrick/1.4.2 (Ruby/2.6.6/2020-03-31)
< Date: Sun, 13 Sep 2020 11:42:43 GMT
< Connection: Keep-Alive
< 
* Connection #0 to host localhost left intact

Now that we passed the correct credentials we see an empty result body with HTTP status code 204, this indicates that the delete was processed successfully.

Repeating the call on GET /messages now shows that the list of messages is empty:

cor@nomac in ~/code/rest_example on git master at 13:42:43 CEST 
➜  curl -v http://localhost:4567/messages                     
*   Trying 127.0.0.1:4567...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 4567 (#0)
> GET /messages HTTP/1.1
> Host: localhost:4567
> User-Agent: curl/7.68.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK 
< Content-Type: application/json
< Content-Length: 15
< X-Content-Type-Options: nosniff
< Server: WEBrick/1.4.2 (Ruby/2.6.6/2020-03-31)
< Date: Sun, 13 Sep 2020 11:43:12 GMT
< Connection: Keep-Alive
< 
* Connection #0 to host localhost left intact
{"messages":[]}%                          

Using Net::HTTP

The Ruby standard library comes with Net::HTTP which is described in its documentation as “rich library to build HTTP user-agents”. The documentation shows examples on how to use it.

A great way to try out bits of code is to use an interactive prompt (often called a REPL, which is short for Read-Eval-Print-Loop). Ruby offers IRB (which is short for Interactive Ruby Prompt) as a part of the standard library, which is what you will see being used in the rest of this article.

None of the code in the standard library is loaded by default, so we have to require “net/http” first:

cor@nomac in ~/code/rest_example on git master at 13:43:12 CEST 
➜  irb
irb(main):001:0> require 'net/http'
=> true

We can do a quick GET request using the get method on the Net::HTTP class to see if we can get the greeting from the service’s root.

irb(main):002:0> Net::HTTP.get('localhost', '/', 4567)
=> "{\"greeting\":\"Hello World\"}"

Great, that works! When doing this you only get the response body though, if you want more information (such as the HTTP status code) you have to use a slightly longer method where you first create a request object which you then send through a Net::HTTP instance. Let’s use this form to save a new message:

irb(main):003:0> user_agent = Net::HTTP.new('localhost', 4567)
=> #<Net::HTTP localhost:4567 open=false>
irb(main):004:0> post_request = Net::HTTP::Post.new('/messages', 'Content-Type' => 'application/json')
=> #<Net::HTTP::Post POST>
irb(main):005:0> post_request.body = '{"message":"This message was sent using Ruby"}'
=> "{\"message\":\"This message was sent using Ruby\"}"
irb(main):006:0> response = user_agent.request(post_request)
=> #<Net::HTTPCreated 201 Created  readbody=true>

The four lines above typed into IRB sent a HTTP POST to /messages which returned HTTP status code 201, meaning that the message got created. Let’s inspect the response:

irb(main):007:0> response.body
=> "{\"id\":1,\"message\":\"This message was sent using Ruby\"}"

Let’s send a second message and inspect the result of that call too:

irb(main):008:0> post_request = Net::HTTP::Post.new('/messages', 'Content-Type' => 'application/json')
=> #<Net::HTTP::Post POST>
irb(main):009:0> post_request.body = '{"message":"This message was sent using Ruby too"}'
=> "{\"message\":\"This message was sent using Ruby too\"}"
irb(main):010:0> response = user_agent.request(post_request)
=> #<Net::HTTPCreated 201 Created  readbody=true>
irb(main):011:0> response.body
=> "{\"id\":2,\"message\":\"This message was sent using Ruby too\"}"

The service has now saved two messages, we can verify that by performing a GET request on /messages (this time using the longer form) like so:

irb(main):012:0> get_request = Net::HTTP::Get.new('/messages')
=> #<Net::HTTP::Get GET>
irb(main):013:0> response = user_agent.request(get_request)
=> #<Net::HTTPOK 200 OK  readbody=true>
irb(main):014:0> response.body
=> "{\"messages\":[{\"id\":1,\"message\":\"This message was sent using Ruby\"},{\"id\":2,\"message\":\"This message was sent using Ruby too\"}]}"

That all works pretty much as expected, now on to the DELETE:

irb(main):015:0> delete_request = Net::HTTP::Delete.new('/messages/1')
=> #<Net::HTTP::Delete DELETE>
irb(main):016:0> response = user_agent.request(delete_request)
=> #<Net::HTTPUnauthorized 401 Unauthorized  readbody=true>

HTTP status code 401 was returned, just like we saw when we were using cURL. In order for the DELETE request to be processed we need to authenticate ourselves:

irb(main):017:0> delete_request.basic_auth 'hello', 'world'
=> ["Basic aGVsbG86d29ybGQ="]
irb(main):018:0> response = user_agent.request(delete_request)
=> #<Net::HTTPNoContent 204 No Content  readbody=true>

Splendid! We were able to match the actions we took in cURL using Ruby’s Net::HTTP through IRB.

Working with JSON

Rather than piecing together JSON strings ourselves (which is what we have done so far) we can use Ruby’s JSON library. With JSON.parse(string) we can convert a JSON string to a Ruby object and with JSON.dump(object) we can convert a Ruby object to a JSON string.

Let’s try this out in our IRB session too:

irb(main):019:0> require 'json'
=> true
irb(main):020:0> JSON.dump({ message: 'This is JSON' })
=> "{\"message\":\"This is JSON\"}"
irb(main):021:0> JSON.parse("{\"message\":\"This is JSON\"}")
=> {"message"=>"This is JSON"}

Like with Net::HTTP we have to require the library before being able to use it. The results above pretty much speak for themselves.

Putting Net::HTTP and JSON together

After having gone through all of the above we can put it all together using IRB once more:

cor@nomac in ~/code/rest_example on git master at 13:58:49 CEST 
➜  irb
irb(main):001:0> require 'net/http'
=> true
irb(main):002:0> require 'json'
=> true
irb(main):003:0> user_agent = Net::HTTP.new('localhost', 4567)
=> #<Net::HTTP localhost:4567 open=false>
irb(main):004:0> request = Net::HTTP::Get.new('/messages')
=> #<Net::HTTP::Get GET>
irb(main):005:0> response = user_agent.request(request)
=> #<Net::HTTPOK 200 OK  readbody=true>
irb(main):006:0> messages = JSON.parse(response.body)
=> {"messages"=>[{"id"=>2, "message"=>"This message was sent using Ruby too"}]}
irb(main):007:0> message_count = messages['messages'].size
=> 1
irb(main):008:0> request = Net::HTTP::Post.new('/messages', 'Content-Type' => 'application/json')
=> #<Net::HTTP::Post POST>
irb(main):009:0> request.body = JSON.dump({ message: "Just Another Message" })
=> "{\"message\":\"Just Another Message\"}"
irb(main):010:0> response = user_agent.request(request)
=> #<Net::HTTPCreated 201 Created  readbody=true>
irb(main):011:0> message = JSON.parse(response.body)
=> {"id"=>3, "message"=>"Just Another Message"}

With the message bodies going in and out as Hashes you can imagine it should be pretty easy to interface this with other code.

Using the above I have put together a very basic client for the example service which you can find in the client.rb file in the example project, you can load this into an IRB session and then use it to talk to the service like so:

irb(main):012:0> require_relative 'client'
=> true
irb(main):013:0> client = Client.new('localhost', 4567, username: 'hello', password: 'world')
=> #<Client:0x00005576a8b9df88 @hostname="localhost", @port=4567, @username="hello", @password="world">
irb(main):014:0> client.messages
=> {"messages"=>[{"id"=>2, "message"=>"This message was sent using Ruby too"}, {"id"=>3, "message"=>"Just Another Message"}]}
irb(main):015:0> client.add_message "I wonder what happened to message 1..."
=> {"id"=>4, "message"=>"I wonder what happened to message 1..."}
irb(main):016:0> client.messages
=> {"messages"=>[{"id"=>2, "message"=>"This message was sent using Ruby too"}, {"id"=>3, "message"=>"Just Another Message"}, {"id"=>4, "message"=>"I wonder what happened to message 1..."}]}
irb(main):017:0> client.remove_message 2
=> nil
irb(main):018:0> client.messages
=> {"messages"=>[{"id"=>3, "message"=>"Just Another Message"}, {"id"=>4, "message"=>"I wonder what happened to message 1..."}]} 

In conclusion

This concludes our little REST API adventure with the Ruby standard library. If your curiosity hadn’t already taken you there I highly recommend that you check out the documentation on Ruby’s documentation website.

If you have read through the Net::HTTP documentation you may find that there is room for improvement in the client code that I wrote for this article (by for example implementing the use of persistent connections, which would be desirable when sending multiple consecutive requests in the same session).

Also when working with real world APIs you will likely see authentication mechanisms other than basic authentication, these are often implemented by sending a token of some sort through HTTP headers. Generally these are things that Net::HTTP can handle just fine.

If you have feedback on this article or have questions based on its contents then feel free to reach out to me on Twitter or through e-mail.