Her is an ORM (Object Relational Mapper) that maps REST resources to Ruby objects. It is designed to build applications that are powered by a RESTful API instead of a database.
In your Gemfile, add:
gem "her"
That’s it!
First, you have to define which API your models will be bound to. For example, with Rails, you would create a new config/initializers/her.rb
file with this line:
# config/initializers/her.rb
Her::API.setup :base_uri => "https://api.example.com"
And then to add the ORM behavior to a class, you just have to include Her::Model
in it:
class User
include Her::Model
end
After that, using Her is very similar to many ActiveModel-like ORMs:
User.all
# GET https://api.example.com/users and return an array of User objects
User.find(1)
# GET https://api.example.com/users/1 and return a User object
@user = User.create(:fullname => "Tobias Fünke")
# POST "https://api.example.com/users" with the data and return a User object
@user = User.new(:fullname => "Tobias Fünke")
@user.occupation = "actor"
@user.save
# POST https://api.example.com/users with the data and return a User object
@user = User.find(1)
@user.fullname = "Lindsay Fünke"
@user.save
# PUT https://api.example.com/users/1 with the data and return+update the User object
Since Her relies on Faraday to send HTTP requests, you can add additional middleware to handle requests and responses. Using a block in the setup
call, you have access to Faraday’s builder
object and are able to customize the middleware stack used on each request and response.
Her doesn’t support any kind of authentication. However, it’s very easy to implement one with a request middleware. Using the builder block, we add it to the default list of middleware.
class MyAuthentication < Faraday::Middleware
def initialize(app, options={})
@options = options
end
def call(env)
env[:request_headers]["X-API-Token"] = @options[:token] if @options.include?(:token)
@app.call(env)
end
end
Her::API.setup :base_uri => "https://api.example.com" do |builder|
# This token could be stored in the client session
builder.use MyAuthentication, :token => "bb2b2dd75413d32c1ac421d39e95b978d1819ff611f68fc2fdd5c8b9c7331192"
end
Now, each HTTP request made by Her will have the X-API-Token
header.
By default, Her handles JSON data. It expects the resource/collection data to be returned at the first level.
Note: Before 0.2, Her expected the resource/collection data to be returned in a data
key within the JSON object. If you want the old behavior, you can use the Her::Middleware::SecondLevelParseJSON
middleware.
// The response of GET /users/1
{ "id" : 1, "name" : "Tobias Fünke" }
// The response of GET /users
[{ "id" : 1, "name" : "Tobias Fünke" }]
However, you can define your own parsing method, using a response middleware. The middleware is expected to set env[:body]
to a hash with three keys: data
, errors
and metadata
. The following code enables parsing JSON data and treating the result as first-level properties. Using the builder block, we then replace the default parser with our custom parser.
class MyCustomParser < Faraday::Response::Middleware
def on_complete(env)
json = MultiJson.load(env[:body], :symbolize_keys => true)
env[:body] = {
:data => json[:result],
:errors => json[:errors],
:metadata => json[:metadata]
}
end
end
Her::API.setup :base_uri => "https://api.example.com" do |builder|
# We use the `swap` method to replace Her’s default parser middleware
builder.swap Her::Middleware::DefaultParseJSON, MyCustomParser
end
# User.find(1) will now expect "https://api.example.com/users/1" to return something like '{ "result" => { "id": 1, "name": "Tobias Fünke" }, "errors" => [] }'
Using the faraday_middleware
and simple_oauth
gems, it’s fairly easy to use OAuth authentication with Her.
In your Gemfile:
gem "her"
gem "faraday_middleware"
gem "simple_oauth"
In your Ruby code:
# Create an application on `https://dev.twitter.com/apps` to set these values
TWITTER_CREDENTIALS = {
:consumer_key => "",
:consumer_secret => "",
:token => "",
:token_secret => ""
}
Her::API.setup :base_uri => "https://api.twitter.com/1/" do |builder|
# We need to insert the middleware at the beginning of the stack (hence the `insert 0`)
builder.insert 0, FaradayMiddleware::OAuth, TWITTER_CREDENTIALS
end
class Tweet
include Her::Model
end
@tweets = Tweet.get("/statuses/home_timeline.json")
Again, using the faraday_middleware
makes it very easy to cache requests and responses:
In your Gemfile:
gem "her"
gem "faraday_middleware"
In your Ruby code:
class MyCache
def initialize
@cache = {}
end
def write(key, value)
@cache[key] = value
end
def read(key)
@cache[key]
end
def fetch(key, &block)
return value = read(key) if value.nil?
write key, yield
end
end
# A cache system must respond to `#write`, `#read` and `#fetch`.
# We should be probably using something like Memcached here, not a global object
$cache = MyCache.new
Her::API.setup :base_uri => "https://api.example.com" do |builder|
builder.use FaradayMiddleware::Caching, $cache
end
class User
include Her::Model
end
@user = User.find(1)
# GET /users/1
@user = User.find(1)
# This request will be fetched from the cache
You can define has_many
, has_one
and belongs_to
relationships in your models. The relationship data is handled in two different ways. If there’s relationship data when parsing a resource, it will be used to create new Ruby objects.
If no relationship data was included when parsing a resource, calling a method with the same name as the relationship will fetch the data (providing there’s an HTTP request available for it in the API).
For example, with this setup:
class User
include Her::Model
has_many :comments
has_one :role
belongs_to :organization
end
class Comment
include Her::Model
end
class Role
include Her::Model
end
class Organization
include Her::Model
end
If there’s relationship data in the resource, no extra HTTP request is made when calling the #comments
method and an array of resources is returned:
@user = User.find(1) # { :data => { :id => 1, :name => "George Michael Bluth", :comments => [{ :id => 1, :text => "Foo" }, { :id => 2, :text => "Bar" }], :role => { :id => 1, :name => "Admin" }, :organization => { :id => 2, :name => "Bluth Company" } }}
@user.comments # => [#<Comment id=1>, #<Comment id=2>] fetched directly from @user
@user.role # => #<Role id=1> fetched directly from @user
@user.organization # => #<Organization id=2> fetched directly from @user
If there’s no relationship data in the resource, an extra HTTP request (to GET /users/1/comments
) is made when calling the #comments
method:
@user = User.find(1) # { :data => { :id => 1, :name => "George Michael Bluth" }}
@user.comments # => [#<Comment id=1>, #<Comment id=2>] fetched from /users/1/comments
For has_one
relationships, an extra HTTP request (to GET /users/1/role
) is made when calling the #role
method:
@user = User.find(1) # { :data => { :id => 1, :name => "George Michael Bluth" }}
@user.role # => #<Role id=1> fetched from /users/1/role
For belongs_to
relationships, an extra HTTP request (to GET /organizations/2
) is made when calling the #organization
method:
@user = User.find(1) # { :data => { :id => 1, :name => "George Michael Bluth", :organization_id => 2 }}
@user.organization # => #<Organization id=2> fetched from /organizations/2
However, subsequent calls to #comments
, #role
and #organization
will not trigger extra HTTP requests as the data has already been fetched.
You can add before and after hooks to your models that are triggered on specific actions (save
, update
, create
, destroy
):
class User
include Her::Model
before_save :set_internal_id
def set_internal_id
self.internal_id = 42 # Will be passed in the HTTP request
end
end
@user = User.create(:fullname => "Tobias Fünke")
# POST /users&fullname=Tobias+Fünke&internal_id=42
You can easily define custom requests for your models using custom_get
, custom_post
, etc.
class User
include Her::Model
custom_get :popular, :unpopular
custom_post :from_default
end
User.popular # => [#<User id=1>, #<User id=2>]
# GET /users/popular
User.unpopular # => [#<User id=3>, #<User id=4>]
# GET /users/unpopular
User.from_default(:name => "Maeby Fünke") # => #<User id=5>
# POST /users/from_default?name=Maeby+Fünke
You can also use get
, post
, put
or delete
(which maps the returned data to either a collection or a resource).
class User
include Her::Model
end
User.get(:popular) # => [#<User id=1>, #<User id=2>]
# GET /users/popular
User.get(:single_best) # => #<User id=1>
# GET /users/single_best
Also, get_collection
(which maps the returned data to a collection of resources), get_resource
(which maps the returned data to a single resource) or get_raw
(which yields the parsed data return from the HTTP request) can also be used. Other HTTP methods are supported (post_raw
, put_resource
, etc.).
class User
include Her::Model
def self.popular
get_collection(:popular)
end
def self.total
get_raw(:stats) do |parsed_data|
parsed_data[:data][:total_users]
end
end
end
User.popular # => [#<User id=1>, #<User id=2>]
User.total # => 42
You can also use full request paths (with strings instead of symbols).
class User
include Her::Model
end
User.get("/users/popular") # => [#<User id=1>, #<User id=2>]
# GET /users/popular
You can define custom HTTP paths for your models:
class User
include Her::Model
collection_path "/hello_users/:id"
end
@user = User.find(1)
# GET /hello_users/1
You can also include custom variables in your paths:
class User
include Her::Model
collection_path "/organizations/:organization_id/users"
end
@user = User.find(1, :_organization_id => 2)
# GET /organizations/2/users/1
@user = User.all(:_organization_id => 2)
# GET /organizations/2/users
@user = User.new(:fullname => "Tobias Fünke", :organization_id => 2)
@user.save
# POST /organizations/2/users
It is possible to use different APIs for different models. Instead of calling Her::API.setup
, you can create instances of Her::API
:
# config/initializers/her.rb
$my_api = Her::API.new
$my_api.setup :base_uri => "https://my_api.example.com"
$other_api = Her::API.new
$other_api.setup :base_uri => "https://other_api.example.com"
You can then define which API a model will use:
class User
include Her::Model
uses_api $my_api
end
class Category
include Her::Model
uses_api $other_api
end
User.all
# GET https://my_api.example.com/users
Category.all
# GET https://other_api.example.com/categories
- Better error handling
- Better API documentation (using YARD)
Feel free to contribute and submit issues/pull requests on GitHub like these fine folks did:
Take a look at the spec
folder before you do, and make sure bundle exec rake spec
passes after your modifications :)
Her is © 2012 Rémi Prévost and may be freely distributed under the MIT license. See the LICENSE
file.