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.
{:.no_toc}
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:
- We may receive an array of hashes
- We may receive a single hash
- We may receive a status code that’s indicative of an error
- And we may receive an absolute disaster of a response
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:
- Hardcode the attributes of a resource
- 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:
- Pray nothing changes and hardcode the variables
- Normalizing the attribute keys before initializing
- Writing a DSL to map the keys from
values
hash to what you want them to be
Rebuttal:
- I’m not a praying type.
- This seems fine, but then sending them back up could cause an issue
- 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:
- If it’s just a hash with a non-error status code, use
new
- If it’s an array with a non-error status code,
map
intonew
- 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:
- Use a block for our
member_method
and handle the response ourselves - Send options to the
member_method
and pass those ontohandle_response
- 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