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 these lines:
# config/initializers/her.rb
Her::API.setup :url => "https://api.example.com" do |connection|
connection.use Faraday::Request::UrlEncoded
connection.use Her::Middleware::DefaultParseJSON
connection.use Faraday::Adapter::NetHttp
end
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 ActiveRecord-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
These are the basic ActiveRecord-like methods you can use with your models:
class User
include Her::Model
end
# Update a fetched resource
user = User.find(1)
user.fullname = "Lindsay Fünke"
# OR user.assign_attributes :fullname => "Lindsay Fünke"
user.save
# Update a resource without fetching it
User.save_existing(1, :fullname => "Lindsay Fünke")
# Destroy a fetched resource
user = User.find(1)
user.destroy
# Destroy a resource without fetching it
User.destroy_existing(1)
# Fetching a collection of resources
User.all
# Create a new resource
User.create(:fullname => "Maeby Fünke")
# Save a new resource
user = User.new(:fullname => "Maeby Fünke")
user.save
You can look into the examples
directory for a sample application using Her. For a complete reference of all the methods you can use, check out the documentation.
Since Her relies on Faraday to send HTTP requests, you can choose the middleware used to handle requests and responses. Using the block in the setup
call, you have access to Faraday’s connection
object and are able to customize the middleware stack used on each request and response.
Her doesn’t support authentication by default. However, it’s easy to implement one with request middleware. Using the connection
block, we can add it to the middleware stack.
For example, to add a token header to your API requests in a Rails application, you could use the excellent request_store
gem like this:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_filter :set_user_api_token
protected
def set_user_api_token
RequestStore.store[:my_api_token] = current_user.api_token # or something similar based on `session`
end
end
# lib/my_token_authentication.rb
class MyTokenAuthentication < Faraday::Middleware
def call(env)
env[:request_headers]["X-API-Token"] = RequestStore.store[:my_api_token]
@app.call(env)
end
end
# config/initializers/her.rb
require "lib/my_token_authentication"
Her::API.setup :url => "https://api.example.com" do |connection|
connection.use MyTokenAuthentication
connection.use Faraday::Request::UrlEncoded
connection.use Her::Middleware::DefaultParseJSON
connection.use Faraday::Adapter::NetHttp
end
Now, each HTTP request made by Her will have the X-API-Token
header.
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 :url => "https://api.twitter.com/1/" do |connection|
connection.use FaradayMiddleware::OAuth, TWITTER_CREDENTIALS
connection.use Her::Middleware::DefaultParseJSON
connection.use Faraday::Adapter::NetHttp
end
class Tweet
include Her::Model
end
@tweets = Tweet.get("/statuses/home_timeline.json")
See the Authentication middleware section for an example of how to pass different credentials based on the current user.
By default, Her handles JSON data. It expects the resource/collection data to be returned at the first level.
// The response of GET /users/1
{ "id" : 1, "name" : "Tobias Fünke" }
// The response of GET /users
[{ "id" : 1, "name" : "Tobias Fünke" }]
However, if you want Her to be able to parse the data from a single root element (usually based on the model name), you’ll have to use the parse_root_in_json
method (See the JSON attributes-wrapping section).
Also, you can define your own parsing method using a response middleware. The middleware should set env[:body]
to a hash with three keys: data
, errors
and metadata
. The following code uses a custom middleware to parse the JSON data:
# Expects responses like:
#
# {
# "result": {
# "id": 1,
# "name": "Tobias Fünke"
# },
# "errors" => []
# }
#
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 :url => "https://api.example.com" do |connection|
connection.use MyCustomParser
connection.use Faraday::Adapter::NetHttp
end
Again, using the faraday_middleware
and memcached
gems makes it very easy to cache requests and responses.
In your Gemfile:
gem "her"
gem "faraday_middleware"
gem "memcached"
In your Ruby code:
Her::API.setup :url => "https://api.example.com" do |connection|
connection.use FaradayMiddleware::Caching, Memcached::Rails.new('127.0.0.1:11211')
connection.use Her::Middleware::DefaultParseJSON
connection.use Faraday::Adapter::NetHttp
end
class User
include Her::Model
end
@user = User.find(1)
# GET /users/1
@user = User.find(1)
# This request will be fetched from memcached
Here’s a list of several useful features available in Her.
You can define has_many
, has_one
and belongs_to
associations in your models. The association data is handled in two different ways.
- If Her finds association data when parsing a resource, that data will be used to create the associated model objects on the resource.
- If no association data was included when parsing a resource, calling a method with the same name as the association will fetch the data (providing there’s an HTTP request available for it in the API).
For example:
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 association 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)
# {
# :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 text="Foo">, #<Comment id=2 text="Bar">]
@user.role
# #<Role id=1 name="Admin">
@user.organization
# #<Organization id=2 name="Bluth Company">
If there’s no association data in the resource, Her makes a HTTP request to retrieve the data.
@user = User.find(1)
# { :id => 1, :name => "George Michael Bluth", :organization_id => 2 }
# has_many association:
@user.comments
# GET /users/1/comments
# [#<Comment id=1>, #<Comment id=2>]
# has_one association:
@user.role
# GET /users/1/role
# #<Role id=1>
# belongs_to association:
@user.organization
# (the organization id comes from :organization_id, by default)
# GET /organizations/2
# #<Organization id=2>
Subsequent calls to #comments
, #role
and #organization
will not trigger extra HTTP requests and will return the cached objects.
Resources must always have all the required attributes to build their complete path. For example, if you have these models:
class User
include Her::Model
collection_path "organizations/:organization_id/users"
end
class Organization
include Her::Model
has_many :users
end
Her expects all User
resources to have an :organization_id
(or :_organization_id
) attribute. Otherwise, calling mostly all methods, like User.all
, will thrown an exception like this one:
Her::Errors::PathError: Missing :_organization_id parameter to build the request path. Path is `organizations/:organization_id/users`. Parameters are `{ … }`.
Her includes ActiveModel::Validations
so you can declare validations the same way you do in Rails.
However, validations must be triggered manually — they are not run, for example, when calling #save
on an object, or #create
on a model class.
class User
include Her::Model
attributes :fullname, :email
validates :fullname, :presence => true
validates :email, :presence => true
end
@user = User.new(:fullname => "Tobias Fünke")
@user.valid? # => false
@user.save
# POST /users&fullname=Tobias+Fünke will still be called, even if the user is not valid
Her includes ActiveModel::Dirty
so you can keep track of the attributes that have changed in an object.
class User
include Her::Model
attributes :fullname, :email
end
@user = User.new(:fullname => "Tobias Fünke")
@user.fullname_changed? # => true
@user.changes # => { :fullname => [nil, "Tobias Fünke"] }
@user.save
# POST /users&fullname=Tobias+Fünke
@user.fullname_changed? # => false
@user.changes # => {}
You can add before and after callbacks to your models that are triggered on specific actions. You can use symbols or blocks.
class User
include Her::Model
before_save :set_internal_id
after_find { |u| u.fullname.upcase! }
def set_internal_id
self.internal_id = 42 # Will be passed in the HTTP request
end
end
@user = User.create(:fullname => "Tobias Funke")
# POST /users&fullname=Tobias+Fünke&internal_id=42
@user = User.find(1)
@user.fullname # => "TOBIAS FUNKE"
The available callbacks are:
before_save
before_create
before_update
before_destroy
after_save
after_create
after_update
after_destroy
after_find
Her supports sending and parsing JSON data wrapped in a root element (to be compatible with Rails’ include_root_in_json
setting), like so:
If you want to send all data to your API wrapped in a root element based on the model name.
class User
include Her::Model
include_root_in_json true
end
class Article
include Her::Model
include_root_in_json :post
end
User.create(:fullname => "Tobias Fünke")
# POST { "user": { "fullname": "Tobias Fünke" } } to /users
Article.create(:title => "Hello world.")
# POST { "post": { "title": "Hello world." } } to /articles
If the API returns data wrapped in a root element based on the model name.
class User
include Her::Model
parse_root_in_json true
end
class Article
include Her::Model
parse_root_in_json :post
end
# POST /users returns { "user": { "fullname": "Tobias Fünke" } }
user = User.create(:fullname => "Tobias Fünke")
user.fullname # => "Tobias Fünke"
# POST /articles returns { "post": { "title": "Hello world." } }
article = Article.create(:title => "Hello world.")
article.title # => "Hello world."
Of course, you can use both include_root_in_json
and parse_root_in_json
at the same time.
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
# GET /users/popular
# [#<User id=1>, #<User id=2>]
User.unpopular
# GET /users/unpopular
# [#<User id=3>, #<User id=4>]
User.from_default(:name => "Maeby Fünke")
# POST /users/from_default?name=Maeby+Fünke
# #<User id=5 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)
# GET /users/popular
# [#<User id=1>, #<User id=2>]
User.get(:single_best)
# GET /users/single_best
# #<User id=1>
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
# GET /users/popular
# [#<User id=1>, #<User id=2>]
User.total
# GET /users/stats
# => 42
You can also use full request paths (with strings instead of symbols).
class User
include Her::Model
end
User.get("/users/popular")
# GET /users/popular
# [#<User id=1>, #<User id=2>]
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 :url => "https://my_api.example.com" do |connection|
connection.use Her::Middleware::DefaultParseJSON
connection.use Faraday::Adapter::NetHttp
end
$other_api = Her::API.new
$other_api.setup :url => "https://other_api.example.com" do |connection|
connection.use Her::Middleware::DefaultParseJSON
connection.use Faraday::Adapter::NetHttp
end
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
When initializing Her::API
, you can pass any parameter supported by Faraday.new
. So to use HTTPS, you can use Faraday’s :ssl
option.
ssl_options = { :ca_path => "/usr/lib/ssl/certs" }
Her::API.setup :url => "https://api.example.com", :ssl => ssl_options do |connection|
connection.use Her::Middleware::DefaultParseJSON
connection.use Faraday::Adapter::NetHttp
end
Suppose we have these two models bound to your API:
# app/models/user.rb
class User
include Her::Model
custom_get :popular
end
# app/models/post.rb
class Post
include Her::Model
custom_get :recent, :archived
end
In order to test them, we’ll have to stub the remote API requests. With RSpec, we can do this like so:
# spec/spec_helper.rb
RSpec.configure do |config|
config.include(Module.new do
def stub_api_for(klass)
klass.uses_api (api = Her::API.new)
# Here, you would customize this for your own API (URL, middleware, etc)
# like you have done in your application’s initializer
api.setup :url => "http://api.example.com" do |connection|
connection.use Her::Middleware::FirstLevelParseJSON
connection.adapter(:test) { |s| yield(s) }
end
end
end)
end
Then, in your tests, we can specify what (fake) HTTP requests will return:
# spec/models/user.rb
describe User do
before do
stub_api_for(User) do |stub|
stub.get("/users/popular") { |env| [200, {}, [{ :id => 1, :name => "Tobias Fünke" }, { :id => 2, :name => "Lindsay Fünke" }].to_json] }
end
end
describe :popular do
subject { User.popular }
its(:length) { should == 2 }
its(:errors) { should be_empty }
end
end
We can redefine the API for a model as many times as we want, like for more complex tests:
# spec/models/user.rb
describe Post do
describe :recent do
before do
stub_api_for(Post) do |stub|
stub.get("/posts/recent") { |env| [200, {}, [{ :id => 1 }, { :id => 2 }].to_json] }
end
end
subject { Post.recent }
its(:length) { should == 2 }
its(:errors) { should be_empty }
end
describe :archived do
before do
stub_api_for(Post) do |stub|
stub.get("/posts/archived") { |env| [200, {}, [{ :id => 1 }, { :id => 2 }].to_json] }
end
end
subject { Post.archived }
its(:length) { should == 2 }
its(:errors) { should be_empty }
end
end
See the UPGRADE.md for backward compability issues.
Most projects I know that use Her are internal or private projects but here’s a list of public ones:
I told myself a few months ago that it would be great to build a gem to replace Rails’ ActiveResource since it was barely maintained (and now removed from Rails 4.0), lacking features and hard to extend/customize. I had built a few of these REST-powered ORMs for client projects before but I decided I wanted to write one for myself that I could release as an open-source project.
Most of Her’s core codebase was written on a Saturday morning of April 2012 (first commit at 7am!).
Yes please! Feel free to contribute and submit issues/pull requests on GitHub.
See CONTRIBUTING.md for best practices.
These fine folks helped with Her:
- @jfcixmedia
- @EtienneLem
- @rafaelss
- @tysontate
- @nfo
- @simonprevost
- @jmlacroix
- @thomsbg
- @calmyournerves
- @luflux
- @simonc
- @pencil
- @joanniclaborde
- @seanreads
- @jonkarna
- @aclevy
Her is © 2012-2013 Rémi Prévost and may be freely distributed under the MIT license. See the LICENSE
file.