How to Create a Ruby JSON HTTP API Client Gem

Chances are you’ve consumed an API in your life. Sometimes, this may have required boilerplate to get started. The convenience factor of a client has clear benefits, as it does most of the heavy lifting for you so you can focus on your application.

In this somewhat lengthy and code-friendly post, we’ll design, architect, and develop an API client that consumes a JSON-based REST service.

While you could use something like RestClient or ActiveResource, that’s not cool.

Contents

Design decisions

Before I write code, I spend time thinking about how I want things to look and feel under the hood. If you’re a building architect, you do the same: you plan and design.

I try to think only one step ahead. “If I need this behavior, how can I accomplish it?” is something I ask myself. I’ll sketch out some interfaces in a scratch pad and see which one feels the most natural.

Designing the Resource class

Here are some of my top-of-mind options (some of these are intentionally worse than others):

CoolService::Post.all(page: 1)
CoolService::Post.find(1)
CoolService::Post.find(1).update(params)
CoolService::Post.approve(1)

This is okay, but it almost quacks like an ActiveRecord object. I don’t want other developers thinking that it behaves like one.

CoolService.posts(page: 1)
CoolService.posts.get(1)
CoolService.posts.update(1, params)
CoolService.posts.approve(1)

I’m not really a fan of this. .posts isn’t very clear to me.

CoolService::Post.list(page: 1)
CoolService::Post.retrieve(1)
CoolService::Post.update(1, params)
CoolService::Post.approve(1)

I like this. Our actions are intentional, easily grepped, and it’s not immediately resembling an ActiveRecord object. It’s also the pattern Stripe uses, and I’m sure PC went through more iterations than this blog post. We’ll stick with this.

Designing the endpoint invocation

Just like in Rails routes, there are two types of actions: collection routes and member routes.

An “unread” collection route:

GET /posts/unread

A “comments” member (subresource) route, or an “approve” (stateful) route:

GET /posts/1/comments
POST /comments/1/approve

With our Resource design, we have a few ways we can call these. Let’s sketch out a few:

CoolService::Post.retrieve(1).approve(params)

I don’t hate this.

CoolService::Post.approve(1, params)

I also don’t hate this, but it has the potential for leaky features if we’re not diligent with our design decisions. For example: If we want to list all approved posts, do we allow CoolService::Post.approved?

Because we’re using 1s and 0s, we can easily make changes later. But it’s important to at least consider the long-term effects of design before irrationally writing code.

Starting the gem

Let’s start by scaffolding a gem. We’ll call it Typicode, since we’ll use https://jsonplaceholder.typicode.com/ for our example endpoints.

$ bundle gem typicode 

And then let’s drop some stuff into typicode.gemspec. Here’s what mine looks like:

Gem::Specification.new do |spec|
  spec.name          = "typicode"
  spec.version       = Typicode::VERSION
  spec.authors       = ["Josh Brody"]
  spec.email         = ["git@josh.mn"]

  spec.summary       = "An example REST client."
  spec.description   = spec.summary
  spec.homepage      = "https://github.com/joshmn/typicode-example"
  spec.license       = "MIT"
  spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")

  spec.metadata["homepage_uri"] = spec.homepage
  spec.metadata["source_code_uri"] = spec.homepage
  spec.metadata["changelog_uri"] = spec.homepage

  # Specify which files should be added to the gem when it is released.
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
  spec.files         = Dir.chdir(File.expand_path('..', __FILE__)) do
    `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
  end

  spec.require_paths = ["lib"]
  spec.add_dependency 'http', '~> 4.4'
end

We’re going to use the httprb library. It’s just my personal preference.

A quick patch

I normally use HTTParty because there truly is no party like an HTTParty, but the benchmarks on HTTPrb are cool. The only thing I miss from HTTParty is the convenience method of #parsed_response. I’ll make a really lazy patch in lib/http_ext.rb to allow for this:

class HTTP::Response
  def parsed_response
    JSON.parse(to_s)
  end 
end

And then require this patch, in addition to the http library at the top of my typicode.rb. Yours should look something like this:

require 'typicode/version'

require 'http'
require 'http_ext'

module Typicode
  class Error < StandardError; end
  # Your code goes here...
end

Configuring our gem

I like blocks because they make me look smart. But attr_accessor works just as well:

Typicode.config.api_key = "my-secret"

Or,

Typicode.configure do |config|
  config.api_key = 'my-secret'
end

We’ll go with the attr_accessor because we don’t gain much from having a block. I personally find that block-based configs are great if you have a myriad of configuration options. We’ll just have an api_key (which we don’t need for our service, but you probably will for yours), and an endpoint attribute.

In lib/typicode/config.rb, let’s write:

module Typicode
  class Config
    # the secret api key that you better not check into your repo!
    attr_accessor :api_key
    
    # the base URL we're consuming
    attr_accessor :endpoint
  end 
end 

Require it in lib/typicode.rb in addition to giving it a method:

require "typicode/version"

require 'http'
require 'http_ext'

require 'typicode/config'

module Typicode
  class Error < StandardError; end

  def self.config
    @config ||= Config.new
  end
end

And because we’re good citizens, a spec in spec/typicode/config_spec.rb:

require 'spec_helper'

RSpec.describe Typicode::Config do
  context '#endpoint' do
    it 'is nil by default' do
      expect(described_class.new.endpoint).to be_nil
    end

    it 'is writable' do
      expect(described_class.new).to respond_to(:endpoint=)
    end

    it 'is readable' do
      expect(described_class.new).to respond_to(:endpoint)
    end
  end

  context '#api_key' do
    it 'is nil by default' do
      expect(described_class.new.api_key).to be_nil
    end

    it 'is writable' do
      expect(described_class.new).to respond_to(:api_key=)
    end

    it 'is readable' do
      expect(described_class.new).to respond_to(:api_key)
    end
  end
end

Let’s run our tests to see if we’re passing:

$ rspec
$ typicode git:(master) ✗ rspec

Typicode::Config
  #endpoint
    is nil by default
    is writable
    is readable
  #api_key
    is nil by default
    is writable
    is readable

Typicode
  has a version number
  does something useful (FAILED - 1)

Nice, kind of. Let’s remove the failing smoke test. It’s in spec/typicode_spec.rb.

it "does something useful" do
  expect(false).to eq(true)
end

And replace it with our config test:

context '.config' do 
  it 'is an instance of Config' do 
    expect(Typicode.config).to be_an_instance_of(Typicode::Config)
  end
end

And test again:

➜  typicode git:(master) ✗ rspec

Typicode::Config
  #endpoint
    is nil by default
    is writable
    is readable
  #api_key
    is nil by default
    is writable
    is readable

Typicode
  has a version number
  .config
    is an instance of Config

Finished in 0.00336 seconds (files took 0.324 seconds to load)
8 examples, 0 failures

Winning.

Some smoke tests, kind of

Let’s write a quick script to get ready to test our code live. Normally, you’d want to have mock responses and specs, but that’d be another post entirely.

I’ll usually install pry for a good REPL:

$ gem install pry

And then in our root folder, create playground.rb:

$LOAD_PATH.unshift File.dirname(__FILE__) + '/lib'

require 'pry'
require_relative './lib/typicode.rb'

puts "hello"

And let’s execute it quick to see if it works:

➜  typicode git:(master) ✗ ruby playground.rb
hello
➜  typicode git:(master)

Nice.

Consuming a Resource

Because the developers of the API we’re consuming did a great job without a lot of gotchas, we can make some assumptions about how we write code to consume the API, and its subsequent responses:

What I’ll usually do is a quick mock to see if I can get something to play nicely. With https://jsonplaceholder.typicode.com/, we have a few different resources. One of which is a Post resource, located at /posts — which returns an array of 100 post-ish objects.

Let’s create lib/typicode/post.rb:

module Typicode
  class Post 
    def self.list
      response = HTTP.get("https://jsonplaceholder.typicode.com/posts")
      response.parsed_response 
    end
  end
end

Make sure we require this in our lib/typicode.rb:

require "typicode/version"

require 'http'
require 'http_ext'

require 'typicode/config'
require 'typicode/post'

module Typicode
  class Error < StandardError; end

  def self.config
    @config ||= Config.new
  end
end

And in our playground.rb, let’s see how it goes:

$LOAD_PATH.unshift File.dirname(__FILE__) + '/lib'

require 'pry'
require_relative './lib/typicode.rb'

puts Typicode::Post.list.size 

Run it:

➜  typicode git:(master) ✗ ruby playground.rb
100
➜  typicode git:(master)

But what about querying the list? Easy:

module Typicode
  class Post 
    def self.list(params = {})
      response = HTTP.get("https://jsonplaceholder.typicode.com/posts", params: params)
      response.parsed_response 
    end
  end
end

Yay!

Typicode gives us the basic REST operations as we expected. Let’s implement those on our Post object:

module Typicode
  class Post 
    def self.list(params = {})
      response = HTTP.get("https://jsonplaceholder.typicode.com/posts", params: params)
      response.parsed_response 
    end
    
    def self.retrieve(id)
      response = HTTP.get("https://jsonplaceholder.typicode.com/posts/#{id}")
      response.parsed_response 
    end
    
    def self.update(id, params)
      response = HTTP.patch("https://jsonplaceholder.typicode.com/posts/#{id}", json: params)
      response.parsed_response 
    end
    
    def self.delete(id)
      response = HTTP.delete("https://jsonplaceholder.typicode.com/posts/#{id}")
      response.parsed_response 
    end
  end
end

And in our playground.rb:

puts Typicode::Post.list.size
puts Typicode::Post.retrieve(1)
puts Typicode::Post.update(1, { title: "yay typicode"})['title']
puts Typicode::Post.delete(1)

Looking good!

Cleaning up HTTP requests

We obviously don’t want to write this boilerplate for every resource. That gets cumbersome, and if we need to change something, we have a lot of busy work to do.

Let’s create a lib/typicode/client.rb to act as our HTTP client:

module Typicode
  class Client 
    def self.execute_api_request(verb, url, **args)
      HTTP.send(verb, url, **args)
    end 
  end
end 

Some metaprogramming here to clean it up, and a fancy looking **args so we don’t need to completely redefine the method we’re calling.

Require it so it gets loaded:

require "typicode/version"

require 'http'
require 'http_ext'

require 'typicode/client'

require 'typicode/config'
require 'typicode/post'

module Typicode
  class Error < StandardError; end

  def self.config
    @config ||= Config.new
  end
end

Modify our post.rb for convenience:

module Typicode
  class Post
    def self.execute_api_request(verb, url, **args)
      Typicode::Client.execute_api_request(verb, url, **args)
    end

    def self.list(query = {})
      response = execute_api_request(:get,"https://jsonplaceholder.typicode.com/posts", params: query)
      response.parsed_response
    end

    def self.retrieve(id)
      response = execute_api_request(:get,"https://jsonplaceholder.typicode.com/posts/#{id}")
      response.parsed_response
    end

    def self.update(id, params)
      response = execute_api_request(:patch,"https://jsonplaceholder.typicode.com/posts/#{id}", json: params)
      response.parsed_response
    end

    def self.delete(id)
      response = execute_api_request(:delete,"https://jsonplaceholder.typicode.com/posts/#{id}")
      response.parsed_response
    end
  end
end

Run playground.rb again:

➜  typicode git:(master) ✗ ruby playground.rb
100
{"userId"=>1, "id"=>1, "title"=>"sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "body"=>"quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"}
yay typicode
{}
➜  typicode git:(master)

Cool.

Obviously, if you had an API key, you’d want to set default headers in client.rb. If this was an Authorization with Bearer token, you could do something like this:

def self.execute_api_request(verb, url, **args)
  HTTP.auth("Bearer #{Typicode.config.api_key}").send(verb, url, **args)
end

While we’re at it, we don’t want to have to specify that root URL every time. Let’s do that here:

def self.execute_api_request(verb, path, **args)
  HTTP.send(verb, "#{Typicode.config.endpoint}#{path}", **args)
end

Note that we’re calling the second argument path and not url anymore. This changes what we expect to send from our resource. Some quick changes to post.rb in our list method shows that we’re now just sending the path, and not an entire URL:

module Typicode
  class Post
    def self.execute_api_request(verb, path, **args)
      Typicode::Client.execute_api_request(verb, path, **args)
    end

    def self.list(params = {})
      response = execute_api_request(:get, "/posts", params: params)
      response.parsed_response
    end
  end
end

In our playground.rb we’ll need to configure our client now:

$LOAD_PATH.unshift File.dirname(__FILE__) + '/lib'

require_relative './lib/typicode.rb'

Typicode.config.endpoint = "https://jsonplaceholder.typicode.com"

puts Typicode::Post.list.size
puts Typicode::Post.retrieve(1)
puts Typicode::Post.update(1, { title: "yay typicode"})['title']
puts Typicode::Post.delete(1)

It still works, amazing.

Abstracting the Resource

Let’s create a class called Resource in lib/typicode/resource.rb and make it look like our post.rb:

module Typicode
  class Resource
    def self.execute_api_request(verb, path, **args)
      Typicode::Client.execute_api_request(verb, path, **args)
    end

    def self.list(params = {})
      response = execute_api_request(:get, "/posts", params: params)
      response.parsed_response
    end

    def self.retrieve(id)
      response = execute_api_request(:get, "/posts/#{id}")
      response.parsed_response
    end

    def self.update(id, params)
      response = execute_api_request(:patch, "/posts/#{id}", json: params)
      response.parsed_response
    end

    def self.delete(id)
      response = execute_api_request(:delete, "/posts/#{id}")
      response.parsed_response
    end
  end
end

We’ll inherit from this class for each of our resources: Post, Comment, Album, Photo, Todo, User. We can’t hardcode posts for each path argument, though. We could get the name of the class as a string and then pluralize it, but pluralization is hard and the pluralize method many Rubyists are used to ship with Ruby but instead ActiveSupport.

We want to keep a minimal set of dependencies, so we’ll do this the hard way and write a method.

Let’s delete our original post.rb and create lib/typicode/resources/post.rb. Normally, I’d advocate for this class to called Typicode::Resources::Post based on its path, but Post is more than acceptable because is a first-class citizen.

module Typicode
  class Post < Resource 
    def self.collection_path
      "posts"
    end
  end
end

In our resource.rb:

module Typicode
  class Resource
    def self.execute_api_request(verb, path, **args)
      Typicode::Client.execute_api_request(verb, path, **args)
    end

    def self.list(params = {})
      response = execute_api_request(:get, "/#{collection_path}", params: params)
      response.parsed_response
    end

    def self.retrieve(id)
      response = execute_api_request(:get, "/#{collection_path}/#{id}")
      response.parsed_response
    end

    def self.update(id, params)
      response = execute_api_request(:patch, "/#{collection_path}/#{id}", json: params)
      response.parsed_response
    end

    def self.delete(id)
      response = execute_api_request(:delete, "/#{collection_path}/#{id}")
      response.parsed_response
    end
  end
end

We’ll make another file, lib/typicode/resources.rb to be a little more clean:

require 'typicode/resource'
require 'typicode/resources/post'

And in lib/typicode.rb, replace our require 'typicode/post' with require 'typicode/resources'.

Give our playground.rb a spin:

➜  typicode git:(master) ✗ ruby playground.rb
100
{"userId"=>1, "id"=>1, "title"=>"sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "body"=>"quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"}
yay typicode
{}
➜  typicode git:(master)

Phenomenal.

Better Resources

Accessing a hash is fine, but it really limits our options as to what we can do with the resource objects in the future.

For initializing an object, we have two choices here:

  1. Hardcode the attributes of a resource
  2. Dynamically define the attributes of a resource

If your API is set in stone, the first option is a good one. But as we know, requirements change over time. Dynamically defining these attributes as they come down isn’t a bad option at all.

Opening up our resource.rb let’s use a little metaprogramming to define some attr_reader and instance variables.

module Typicode
  class Resource 
    def initialize(values)
      values.each do |k,v|
        self.class.attr_reader k.to_sym 
        instance_variable_set(:"@#{k}", v)
      end
    end 
  end 
end 

In this same file, let’s test it by changing our Resource.retrieve method to initialize a new object:

def self.retrieve(id)
  response = execute_api_request(:get, "/#{collection_path}/#{id}")
  new(response.parsed_response)
end

Running playground.rb:

➜  typicode git:(master) ✗ ruby playground.rb
100
#<Typicode::Post:0x00007fdfff024d60>
yay typicode
{}
➜  typicode git:(master) ✗ ruby playground.rb

Hot-dog!

There’s one caveat with this, though, and it’s really up to the API: Typicode’s response has camelCase keys in their JSON.

Ruby-friendly attributes

In the event that the response has attributes that aren’t per our expectations as Rubyists, we have some options:

  1. Pray nothing changes and hardcode the variables
  2. Normalizing the attribute keys before initializing
  3. Writing a DSL to map the keys from values hash to what you want them to be

Rebuttal:

  1. I’m not a praying type.
  2. This seems fine, but then sending them back up could cause an issue
  3. Sounds fun

Transforming attributes with a DSL

Again, I think of design before I write code.

Some options, top-of-mind:

transform_keys userId: :user_id,
               potatoCake: :potato_cake

Actually, that’s really the only option I can think of.

Naturally, you may look at this and go “we can just underscore them!” — but in the event that they’re non-standard, you’re going to be writing more explicit code than you want to in order to capture each edge case. And there will be edge cases.

There’s really only one option I see here, and I hate the name. transform_attribute sounds like we’re doing something to the value.

Let’s play around with this and see what we can do before we actually implement it:

In spec/fake_class_spec.rb:

require 'spec_helper'

class FakeClass
  def self.transformed_keys
    @transformed_keys ||= {}
  end

  def self.transform_keys(hash)
    @transformed_keys = hash
  end

  transform_keys userId: :user_id,
                 potatoCake: :potato_cake

  def initialize(values)
    values.each do |k,v|
      sym = k.to_sym
      if self.class.transformed_keys.key?(sym)
        name = self.class.transformed_keys[sym]
      else
        name = sym
      end
      self.class.attr_reader name
      instance_variable_set(:"@#{name}", v)
    end
  end
end

RSpec.describe FakeClass do
  let(:values) { {"userId" => 1, "potatoCake" => 'yum', "popsicle_stick" => "wood", "notTransformed" => "nope" } }
  let(:instance) { described_class.new(values) }

  context '#userId' do
    it 'is #user_id' do
      expect(instance.user_id).to eq(values['userId'])
    end

    it 'is not defined as userId' do
      expect(instance).to_not respond_to(:userId)
    end
  end

  context '#potatoCake' do
    it 'is #potato_cake' do
      expect(instance.potato_cake).to eq(values['potatoCake'])
    end

    it 'is not defined as userId' do
      expect(instance).to_not respond_to(:potatoCake)
    end
  end

  context '#notTransformed' do
    it 'is #notTransformed' do
      expect(instance.notTransformed).to eq(values['notTransformed'])
    end

    it 'is not defined as not_transformed' do
      expect(instance).to_not respond_to(:not_transformed)
    end
  end
end

Running $ rspec spec/fake_class_spec.rb should return green. We like green.

Now that we have this DSL, let’s extract it and put it in lib/typicode/transform_keys.rb:

module Typicode
  module TransformKeys
    def transformed_keys
      @transformed_keys ||= {}
    end

    def transform_keys(hash)
      @transformed_keys = hash
    end
  end
end

And in our typicode/resource.rb, let’s require this file, and extend this module while rewriting our initializer:

require 'typicode/transform_keys'

module Typicode
  class Resource
    extend Typicode::TransformKeys

    def initialize(values)
      values.each do |k,v|
        sym = k.to_sym
        if self.class.transformed_keys.key?(sym)
          name = self.class.transformed_keys[sym]
        else
          name = sym
        end
        self.class.attr_reader name
        instance_variable_set(:"@#{name}", v)
      end
    end
  end
end 

Finally, move our fake_class_spec.rb into spec/typicode/transform_keys_spec.rb and stub it out:

require 'spec_helper'

class FakeResource < Typicode::Resource
  transform_keys userId: :user_id,
                 potatoCake: :potato_cake
end

RSpec.describe FakeResource do
  let(:values) { {"userId" => 1, "potatoCake" => 'yum', "popsicle_stick" => "wood", "notTransformed" => "nope" } }
  let(:instance) { described_class.new(values) }

  context '#userId' do
    it 'is #user_id' do
      expect(instance.user_id).to eq(values['userId'])
    end

    it 'is not defined as userId' do
      expect(instance).to_not respond_to(:userId)
    end
  end

  context '#potatoCake' do
    it 'is #potato_cake' do
      expect(instance.potato_cake).to eq(values['potatoCake'])
    end

    it 'is not defined as userId' do
      expect(instance).to_not respond_to(:potatoCake)
    end
  end

  context '#notTransformed' do
    it 'is #notTransformed' do
      expect(instance.notTransformed).to eq(values['notTransformed'])
    end

    it 'is not defined as not_transformed' do
      expect(instance).to_not respond_to(:not_transformed)
    end
  end
end

Run $ rspec again and we should be green.

Handling responses

Right now, we’re assuming that everything is kosher and returning a hash from our response. This isn’t going to work in the real world.

In api_resource.rb, let’s create a method called handle_response:

def self.handle_response(response)
  response.parsed_response
end 

We’ll leave this for now and come back to it later: We’re going to clean up api_resource.rb first.

Extracting REST operations

In a real-world scenario, not all operations are going to have the same operations. In our case, this is list, retrieve, update, and delete.

Let’s move these out for cleanliness into their own modules. I’ll put mine under typicode/api_operations:

In typicode/api_operations/list.rb:

module Typicode
  module APIOperations
    module List
      def list(params = {})
        response = execute_api_request(:get, "/#{collection_path}", params: params)
        response.parsed_response
      end
    end
  end
end

Note we’re removing the reference to self in the method call as we’ll be using extend on our resource class.

While we’re here, let’s invoke our new handle_response method instead of just returning response.parsed_response:

module Typicode
  module APIOperations
    module List
      def list(query = {})
        response = execute_api_request(:get, "/#{collection_path}", params: query)
        handle_response(response)
      end
    end
  end
end

Do the same for the other methods as well, creating Typicode::APIOperations::Retrieve as retrieve.rb, Typicode::APIOperations::Delete as delete.rb, Typicode::APIOperations::Update as update.rb.

Finally, create an typicode/api_operations.rb file and require them all:

require 'typicode/api_operations/list'
require 'typicode/api_operations/retrieve'
require 'typicode/api_operations/update'
require 'typicode/api_operations/delete'

And in typicode.rb, require it above typicode/resources:

require "typicode/version"

require 'http'
require 'http_ext'

require 'typicode/client'

require 'typicode/config'
require 'typicode/api_operations'
require 'typicode/resources'

module Typicode
  class Error < StandardError; end

  def self.config
    @config ||= Config.new
  end
end

Now, in post.rb, we know that it has the four operations. Let’s use extend in the class to add this functionality:

module Typicode
  class Post < Resource
    extend Typicode::APIOperations::List 
    extend Typicode::APIOperations::Retrieve
    extend Typicode::APIOperations::Update
    extend Typicode::APIOperations::Delete
    
    def self.collection_path
      "posts"
    end
  end
end

And run our playground.rb again, just to check:

➜  typicode git:(master) ✗ ruby playground.rb     
100
{"userId"=>1, "id"=>1, "title"=>"sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "body"=>"quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"}
yay typicode
{}
➜  typicode git:(master)

Dope!

Handling responses

Right now, we’re just assuming that JSON is coming down and returning it from each request. This is fine but we’ll want to do more.

Let’s make each resource be a resource object instead of a hash. Some things to consider, though:

  1. If it’s just a hash with a non-error status code, use new
  2. If it’s an array with a non-error status code, map into new
  3. If it’s an error-based status code (4xx-5xx), return some sort of error object

In our resource.rb, let’s write some really naive response handling:

def self.handle_response(response)
  if response.code.between?(200, 399)
    if response.parsed_response.is_a?(Hash)
      new(response.parsed_response)
    elsif response.parsed_response.is_a?(Array)
      response.parsed_response.map { |object| new(object) }
    else
      raise ArgumentError, "unknown handling of response with type #{response.parsed_response.class}"
    end
  else
    begin
      Typicode::ErrorObject.new(response.parsed_response)
    rescue StandardError => e
      Typicode::ReallyBadError.new(e, response)
    end
  end
end

We introduced two new classes here, Typicode::ErrorObject and Typicode::ReallyBadError. Let’s plop those in typicode/errors.rb:

module Typicode
  class ErrorObject
    attr_reader :code, :message
    def initialize(response)
      @code = response.code
      @message = response.parsed_response['message']
    end
  end
  
  class ReallyBadError
    attr_reader :exception, :response
    def initialize(exception, response)
      @exception = exception
      @response = response
    end
  end
end

Typicode’s service doesn’t currently support errors, so we’ll just pretend it does for now.

Make sure to require this in typicode.rb:

require "typicode/version"

require 'http'
require 'http_ext'

require 'typicode/client'

require 'typicode/config'
require 'typicode/errors'
require 'typicode/api_operations'
require 'typicode/resources'

module Typicode
  class Error < StandardError; end

  def self.config
    @config ||= Config.new
  end
end

We’ll need to make a slight change to our playground.rb file. Instead of accessing the title attribute via [], we’ll just call it directly on the object now:

$LOAD_PATH.unshift File.dirname(__FILE__) + '/lib'

require_relative './lib/typicode.rb'

Typicode.config.endpoint = "https://jsonplaceholder.typicode.com"

puts Typicode::Post.list.size
puts Typicode::Post.retrieve(1).inspect
puts Typicode::Post.update(1, { title: "yay typicode"}).title
puts Typicode::Post.delete(1)

More resources

Now that we have a good foundation for a resource object, we can copy-pasta our post.rb into the other resources: Comment, Album, Photo, Todo, User.

Let’s do that following the same pattern, renaming the class, and changing the collection_path value. Make sure that you require these new files into resources.rb as well.

Since we’re here, we might as well use our transform_keys syntax on post.rb to make our attributes easier to mangle:

module Typicode
  class Post < Resource
    extend Typicode::APIOperations::List
    extend Typicode::APIOperations::Retrieve
    extend Typicode::APIOperations::Update
    extend Typicode::APIOperations::Delete

    transform_keys userId: :user_id
    
    def self.collection_path
      "posts"
    end
  end
end

Give playground.rb a spin again:

#<Typicode::Post:0x00007f95e169ba08 @user_id=1, @id=1, @title="sunt aut facere repellat provident occaecati excepturi optio reprehenderit", @body="quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto">

The most important part is working!

Updating objects with non-standard keys

We don’t want our users to have to camelCase keys when they’re calling update, so let’s normalize them.

Create a typicode/utils.rb class:

module Typicode
  class Utils 
    class << self 
      def normalize_attributes(attributes, klass)
        return attributes if klass.transformed_keys.empty?
        
        inverted_transformed_keys = klass.transformed_keys.invert 
        new_attributes = {}
        attributes.each do |k,v|
          sym = k.to_sym 
          if inverted_transformed_keys.key?(sym)
            new_attributes[inverted_transformed_keys[sym]] = attributes[k]
          else
            new_attributes[sym] = attributes[k]
          end 
        end
        
        new_attributes 
      end
    end
  end
end

require this bad boy in typicode.rb and then make a change to your Typicode::APIOperations::Update.update method:

module Typicode
  module APIOperations
    module Update
      def update(id, params)
        response = execute_api_request(:patch, "/#{collection_path}/#{id}", json: Utils.normalize_attributes(params, self))
        handle_response(response)
      end
    end
  end
end

A small change to playground.rb make sure that things are going smoothly:

puts Typicode::Post.update(1, { user_id: 123123}).user_id

Run it to check:

➜  typicode git:(master) ✗ ruby playground.rb
100
#<Typicode::Post:0x00007fab2b6aee50 @user_id=1, @id=1, @title="sunt aut facere repellat provident occaecati excepturi optio reprehenderit", @body="quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto">
123123
#<Typicode::Post:0x00007fab2b66f8b8>
➜  typicode git:(master)

It’s like Disney-level magic.

Non-standard REST routes

With JSONPlaceholder, we don’t have the luxury of having any non-resourceful routes. Let’s pretend we do though:

In this example, let’s assume we have a POST /posts/1/approve call. What are our options to design this?

class Post 
  def self.approve(id)
    response = execute_api_request(:post, "/#{collection_path}/#{id}/approve")
    handle_response(response)
  end
end

Or maybe something like:

class Post 
  def approve
    response = self.class.execute_api_request(:post, "/#{self.class.collection_path}/#{id}/approve")
    self.class.handle_response(response)
  end
end

Either is fine, but in the spirit of this client let’s go with the former. However, writing this is rather terse, so let’s clean it up.

We’ll write a method called member_method which accepts some arguments: name, verb, and an optional path. We’ll put this in resource.rb:

def self.member_method(name, verb:, path: nil)
  path ||= name.to_s
  
  define_singleton_method(name) do |id, **args|
    response = execute_api_request(verb.to_sym, "/#{collection_path}/#{id}/#{path}", **args) 
    handle_response(response)
  end
end

We could also theoretically allow a user to call Typicode::Post.retrieve(1).approve:

def self.resource_method(name, verb:, path: nil)
  path ||= name.to_s
  
  define_method(name) do |**args|
    response = self.class.execute_api_request(verb.to_sym, "/#{self.class.collection_path}/#{self.id}/#{path}", **args) 
    self.class.handle_response(response)
  end
end

But again, that wouldn’t be in the spirit of this client, so we’ll ignore that for now. Just know that it’s a possibility.

Anyway, we can can plop member_method this into post.rb to check it out:

module Typicode
  class Post < Resource
    extend Typicode::APIOperations::List
    extend Typicode::APIOperations::Retrieve
    extend Typicode::APIOperations::Update
    extend Typicode::APIOperations::Delete

    transform_keys userId: :user_id

    member_method :approve, :post 
    
    def self.collection_path
      "posts"
    end
  end
end

Giving it a try on playground.rb won’t do anything because our service doesn’t respond to it.

Similarly, we could have a collection_method:

def self.collection_method(name, verb:, path: nil)
  path ||= name.to_s
  
  define_singleton_method(name) do |**args|
    response = execute_api_request(verb.to_sym, "/#{collection_path}/#{path}", **args) 
    handle_response(response)
  end
end

Nested routes

Similar to our member_method above, we can access nested routes with our service. Post has many Comment, for example.

We could write this:

module Typicode
  class Post < Resource
    extend Typicode::APIOperations::List
    extend Typicode::APIOperations::Retrieve
    extend Typicode::APIOperations::Update
    extend Typicode::APIOperations::Delete

    transform_keys userId: :user_id

    member_method :comments, verb: :get  
    
    def self.collection_path
      "posts"
    end
  end
end

Slight change to playground.rb to see if it works:

puts Typicode::Post.comments(1).first.inspect

Running it gives us:

➜  typicode git:(master) ✗ ruby playground.rb     
#<Typicode::Post:0x00007fd52a81ce80 @postId=1, @id=1, @name="id labore ex et quam laborum", @email="Eliseo@gardner.biz", @body="laudantium enim quasi est quidem magnam voluptate ipsam eos\ntempora quo necessitatibus\ndolor quam autem quasi\nreiciendis et nam sapiente accusantium">

That is certainly not a Comment object, but we should expect it to be. This is happening because handle_response converts the object to the class it’s currently being called on.

We have a few options here:

  1. Use a block for our member_method and handle the response ourselves
  2. Send options to the member_method and pass those onto handle_response
  3. Convert to a generic object

I like 2. It’s low-effort but high-reward.

Let’s change APIResource.handle_response to accept an optional hash of options:

def self.handle_response(response, options = {})

And in this, let’s default object_class to self if it’s not passed in the options.

def self.handle_response(response, options = {})
  object_class = options.fetch(:object_class, self)
  # ... 
end

Instead of calling new, let’s call object_class.new:

def self.handle_response(response, options = {})
  object_class = options.fetch(:object_class, self)

  if response.code.between?(200, 399)
    if response.parsed_response.is_a?(Hash)
      object_class.new(response.parsed_response)
    elsif response.parsed_response.is_a?(Array)
      response.parsed_response.map { |object| object_class.new(object) }
    else
      raise ArgumentError, "unknown handling of response with type #{response.parsed_response.class}"
    end
  else
    begin
      Typicode::ErrorObject.new(response)
    rescue StandardError => e
      Typicode::ReallyBadError.new(e, response)
    end
  end
end

Back to our member_method definition:

def self.member_method(name, verb:, path: nil, options: {})
  path ||= name.to_s

  define_singleton_method(name) do |id, **args|
    response = execute_api_request(verb.to_sym, "/#{collection_path}/#{id}/#{path}", **args)
    handle_response(response, options)
  end
end

And then in post.rb:

member_method :comments, verb: :get, options: { object_class: Comment }

Checking playground.rb:

➜  typicode git:(master) ✗ ruby playground.rb
#<Typicode::Comment:0x00007f997d80d040 @postId=1, @id=1, @name="id labore ex et quam laborum", @email="Eliseo@gardner.biz", @body="laudantium enim quasi est quidem magnam voluptate ipsam eos\ntempora quo necessitatibus\ndolor quam autem quasi\nreiciendis et nam sapiente accusantium">

Tada.

Conclusion

Making a client to consume a REST API is rather fun. Making good design decisions along the way makes it easy for both the end-user and developer.

Source code

You can find the source code of this library at https://github.com/joshmn/typicode