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!
For a complete reference of all the methods you can use, check out the documentation.
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.rbHer::API.setupurl: "https://api.example.com"do |c|
# Requestc.useFaraday::Request::UrlEncoded# Responsec.useHer::Middleware::DefaultParseJSON# Adapterc.useFaraday::Adapter::NetHttpendAnd then to add the ORM behavior to a class, you just have to include Her::Model in it:
classUserincludeHer::ModelendAfter 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 objectsUser.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 `fullname=Tobias+Fünke` and return the saved User object@user=User.new(fullname: "Tobias Fünke")@user.occupation="actor"@user.save# POST "https://api.example.com/users" with `fullname=Tobias+Fünke&occupation=actor` and return the saved User object@user=User.find(1)@user.fullname="Lindsay Fünke"@user.save# PUT "https://api.example.com/users/1" with `fullname=Lindsay+Fünke` and return the updated User objectThese are the basic ActiveRecord-like methods you can use with your models:
classUserincludeHer::Modelend# Update a fetched resourceuser=User.find(1)user.fullname="Lindsay Fünke"# OR user.assign_attributes(fullname: "Lindsay Fünke")user.save# returns false if it fails, errors in user.response_errors array# PUT "/users/1" with `fullname=Lindsay+Fünke`user.update_attributes(fullname: "Maeby Fünke")# PUT "/users/1" with `fullname=Maeby+Fünke`# => PUT /users/1 { "id": 1, "name": "new new name" }# Update a resource without fetching itUser.save_existing(1,fullname: "Lindsay Fünke")# PUT "/users/1" with `fullname=Lindsay+Fünke`# Destroy a fetched resourceuser=User.find(1)user.destroy# DELETE "/users/1"# Destroy a resource without fetching itUser.destroy_existing(1)# DELETE "/users/1"# Fetching a collection of resourcesUser.all# GET "/users"User.where(moderator: 1).all# GET "/users?moderator=1"# Create a new resourceUser.create(fullname: "Maeby Fünke")# POST "/users" with `fullname=Maeby+Fünke`# Save a new resourceuser=User.new(fullname: "Maeby Fünke")user.save!# raises Her::Errors::ResourceInvalid if it fails# POST "/users" with `fullname=Maeby+Fünke`You can look into the her-example repository for a sample application using Her.
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 setup 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.rbclassApplicationController < ActionController::Basebefore_filter:set_user_api_tokenprotecteddefset_user_api_tokenRequestStore.store[:my_api_token]=current_user.api_token# or something similar based on `session`endend# lib/my_token_authentication.rbclassMyTokenAuthentication < Faraday::Middlewaredefcall(env)env[:request_headers]["X-API-Token"]=RequestStore.store[:my_api_token]@app.call(env)endend# config/initializers/her.rbrequire"lib/my_token_authentication"Her::API.setupurl: "https://api.example.com"do |c|
# Requestc.useMyTokenAuthenticationc.useFaraday::Request::UrlEncoded# Responsec.useHer::Middleware::DefaultParseJSON# Adapterc.useFaraday::Adapter::NetHttpendNow, each HTTP request made by Her will have the X-API-Token header.
Her can use basic http auth by adding a line to your initializer
# config/initializers/her.rbHer::API.setupurl: "https://api.example.com"do |c|
# Requestc.useFaraday::Request::BasicAuthentication,'myusername','mypassword'c.useFaraday::Request::UrlEncoded# Responsec.useHer::Middleware::DefaultParseJSON# Adapterc.useFaraday::Adapter::NetHttpendUsing 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 valuesTWITTER_CREDENTIALS={consumer_key: "",consumer_secret: "",token: "",token_secret: ""}Her::API.setupurl: "https://api.twitter.com/1/"do |c|
# Requestc.useFaradayMiddleware::OAuth,TWITTER_CREDENTIALS# Responsec.useHer::Middleware::DefaultParseJSON# Adapterc.useFaraday::Adapter::NetHttpendclassTweetincludeHer::Modelend@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 symbol 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": []# }#classMyCustomParser < Faraday::Response::Middlewaredefon_complete(env)json=MultiJson.load(env[:body],symbolize_keys: true)env[:body]={data: json[:result],errors: json[:errors],metadata: json[:metadata]}endendHer::API.setupurl: "https://api.example.com"do |c|
# Responsec.useMyCustomParser# Adapterc.useFaraday::Adapter::NetHttpendAgain, 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.setupurl: "https://api.example.com"do |c|
# Requestc.useFaradayMiddleware::Caching,Memcached::Rails.new('127.0.0.1:11211')# Responsec.useHer::Middleware::DefaultParseJSON# Adapterc.useFaraday::Adapter::NetHttpendclassUserincludeHer::Modelend@user=User.find(1)# GET "/users/1"@user=User.find(1)# This request will be fetched from memcachedHere’s a list of several useful features available in Her.
Examples use this code:
classUserincludeHer::Modelhas_many:commentshas_one:rolebelongs_to:organizationendclassCommentincludeHer::ModelendclassRoleincludeHer::ModelendclassOrganizationincludeHer::ModelendYou 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, 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)# GET "/users/1", response is:# {# "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)# GET "/users/1", response is { "id": 1, "name": "George Michael Bluth", "organization_id": 2 }# has_many association:@user.comments# GET "/users/1/comments"# => [#<Comment id=1>, #<Comment id=2>]@user.comments.where(approved: 1)# GET "/users/1/comments?approved=1"# => [#<Comment id=1>]# 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.
You can use the association methods to build new objects and save them.
@user=User.find(1)@user.comments.build(body: "Just a draft")# => [#<Comment body="Just a draft" user_id=1>]@user.comments.create(body: "Hello world.",user_id: 1)# POST "/comments" with `body=Hello+world.&user_id=1`# => [#<Comment id=3 body="Hello world." user_id=1>]You can also explicitly request a new object via the API when using build. This is useful if you're dealing with default attributes.
classCommentincludeHer::Modelrequest_new_object_on_buildtrueend@user=User.find(1)@user.comments.build(body: "Just a draft")# GET "/users/1/comments/new" with `body=Just+a+draft.`# => [#<Comment id=nil body="Just a draft" archived=false user_id=1>]Resources must always have all the required attributes to build their complete path. For example, if you have these models:
classUserincludeHer::Modelcollection_path"organizations/:organization_id/users"endclassOrganizationincludeHer::Modelhas_many:usersendHer expects all User resources to have an :organization_id (or :_organization_id) attribute. Otherwise, calling mostly all methods, like User.all, will throw an exception like this one:
Her::Errors::PathError: Missing:_organization_idparametertobuildtherequestpath.Pathis`organizations/:organization_id/users`.Parametersare`{ … }`.Associations can also be made using custom attributes:
classUserincludeHer::Modelbelongs_to:owns,class_name: "Organization"endclassOrganizationincludeHer::Modelhas_many:owners,class_name: "User"endHer 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.
classUserincludeHer::Modelattributes:fullname,:emailvalidates:fullname,presence: truevalidates:email,presence: trueend@user=User.new(fullname: "Tobias Fünke")@user.valid?# => false@user.save# POST "/users" with `fullname=Tobias+Fünke` will still be called, even if the user is not validHer includes ActiveModel::Dirty so you can keep track of the attributes that have changed in an object.
classUserincludeHer::Modelattributes:fullname,:emailend@user=User.new(fullname: "Tobias Fünke")@user.fullname_changed?# => true@user.changes# => { :fullname => [nil, "Tobias Fünke"] }@user.save# POST "/users" with `fullname=Tobias+Fünke`@user.fullname_changed?# => false@user.changes# => {}To update only the modified attributes specify :send_only_modified_attributes => true in the setup.
You can add before and after callbacks to your models that are triggered on specific actions. You can use symbols or blocks.
classUserincludeHer::Modelbefore_save:set_internal_idafter_find{ |u| u.fullname.upcase!}defset_internal_idself.internal_id=42# Will be passed in the HTTP requestendend@user=User.create(fullname: "Tobias Fünke")# POST "/users" with `fullname=Tobias+Fünke&internal_id=42`@user=User.find(1)@user.fullname# => "TOBIAS FUNKE"The available callbacks are:
before_savebefore_createbefore_updatebefore_destroyafter_saveafter_createafter_updateafter_destroyafter_findafter_initialize
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.
classUserincludeHer::Modelinclude_root_in_jsontrueendclassArticleincludeHer::Modelinclude_root_in_json:postendUser.create(fullname: "Tobias Fünke")# POST "/users" with `user[fullname]=Tobias+Fünke`Article.create(title: "Hello world.")# POST "/articles" with `post[title]=Hello+world`If the API returns data wrapped in a root element based on the model name.
classUserincludeHer::Modelparse_root_in_jsontrueendclassArticleincludeHer::Modelparse_root_in_json:postenduser=User.create(fullname: "Tobias Fünke")# POST "/users" with `fullname=Tobias+Fünke`, response is { "user": { "fullname": "Tobias Fünke" } }user.fullname# => "Tobias Fünke"article=Article.create(title: "Hello world.")# POST "/articles" with `title=Hello+world.`, response is { "post": { "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.
If the API returns data in the default format used by the ActiveModel::Serializers project you need to configure Her as follows:
classUserincludeHer::Modelparse_root_in_jsontrue,format: :active_model_serializersenduser=Users.find(1)# GET "/users/1", response is { "user": { "id": 1, "fullname": "Lindsay Fünke" } }users=Users.all# GET "/users", response is { "users": [{ "id": 1, "fullname": "Lindsay Fünke" }, { "id": 1, "fullname": "Tobias Fünke" }] }To consume a JSON API 1.0 compliant service, it must return data in accordance with the JSON API spec. The general format of the data is as follows:
{ "data": {
"type": "developers",
"id": "6ab79c8c-ec5a-4426-ad38-8763bbede5a7",
"attributes": {
"language": "ruby",
"name": "avdi grimm",
}
}Then to setup your models:
classContributorincludeHer::JsonApi::Model# defaults to demodulized, pluralized class name, e.g. contributorstype:developersendFinally, you'll need to use the included JsonApiParser Her middleware:
Her::API.setupurl: 'https://my_awesome_json_api_service'do |c|
# Requestc.useFaradayMiddleware::EncodeJson# Responsec.useHer::Middleware::JsonApiParser# Adapterc.useFaraday::Adapter::NetHttpendYou can easily define custom requests for your models using custom_get, custom_post, etc.
classUserincludeHer::Modelcustom_get:popular,:unpopularcustom_post:from_default,:activateendUser.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" with `name=Maeby+Fünke`# => #<User id=5 name="Maeby Fünke">User.activate(id: 6)# POST "/users/6/activate"# => #<User id=6>You can also use get, post, put or delete (which maps the returned data to either a collection or a resource).
classUserincludeHer::ModelendUser.get(:popular)# GET "/users/popular"# => [#<User id=1>, #<User id=2>]User.get(:single_best)# GET "/users/single_best"# => #<User id=1>You can also use get_raw which yields the parsed data and the raw response from the HTTP request. Other HTTP methods are supported (post_raw, put_raw, etc.).
classUserincludeHer::Modeldefself.totalget_raw(:stats)do |parsed_data,response|
parsed_data[:data][:total_users]endendendUser.total# GET "/users/stats"# => 42You can also use full request paths (with strings instead of symbols).
classUserincludeHer::ModelendUser.get("/users/popular")# GET "/users/popular"# => [#<User id=1>, #<User id=2>]You can define custom HTTP paths for your models:
classUserincludeHer::Modelcollection_path"/hello_users/:id"end@user=User.find(1)# GET "/hello_users/1"You can also include custom variables in your paths:
classUserincludeHer::Modelcollection_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" with `fullname=Tobias+Fünke`If your record uses an attribute other than :id to identify itself, specify it using the primary_key method:
classUserincludeHer::Modelprimary_key:_idenduser=User.find("4fd89a42ff204b03a905c535")# GET "/users/4fd89a42ff204b03a905c535", response is { "_id": "4fd89a42ff204b03a905c535", "name": "Tobias" }user.destroy# DELETE "/users/4fd89a42ff204b03a905c535"If all your models share the same settings, you might want to make them children of a class and only include Her::Model in that class. However, there are a few settings that don’t get passed to the children classes:
root_elementcollection_pathandresource_path
Those settings are based on the class name, so you don’t have to redefine them each time you create a new children class (but you still can). Every other setting is inherited from the parent (associations, scopes, JSON settings, etc.).
moduleMyAPIclassModelincludeHer::Modelparse_root_in_jsontrueinclude_root_in_jsontrueendendclassUser < MyAPI::ModelendUser.find(1)# GET "/users/1"Just like with ActiveRecord, you can define named scopes for your models. Scopes are chainable and can be used within other scopes.
classUserincludeHer::Modelscope:by_role,->(role){where(role: role)}scope:admins,->{by_role('admin')}scope:active,->{where(active: 1)}end@admins=User.admins# GET "/users?role=admin"@moderators=User.by_role('moderator')# GET "/users?role=moderator"@active_admins=User.active.admins# @admins.active would have worked here too# GET "/users?role=admin&active=1"A neat trick you can do with scopes is interact with complex paths.
classUserincludeHer::Modelcollection_path"organizations/:organization_id/users"scope:for_organization,->(id){where(organization_id: id)}end@user=User.for_organization(3).find(2)# GET "/organizations/3/users/2"@user=User.for_organization(3).create(fullname: "Tobias Fünke")# POST "/organizations/3" with `fullname=Tobias+Fünke`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.rbMY_API=Her::API.newMY_API.setupurl: "https://my-api.example.com"do |c|
# Responsec.useHer::Middleware::DefaultParseJSON# Adapterc.useFaraday::Adapter::NetHttpendOTHER_API=Her::API.newOTHER_API.setupurl: "https://other-api.example.com"do |c|
# Responsec.useHer::Middleware::DefaultParseJSON# Adapterc.useFaraday::Adapter::NetHttpendYou can then define which API a model will use:
classUserincludeHer::Modeluse_apiMY_APIendclassCategoryincludeHer::Modeluse_apiOTHER_APIendUser.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.setupurl: "https://api.example.com",ssl: ssl_optionsdo |c|
# Responsec.useHer::Middleware::DefaultParseJSON# Adapterc.useFaraday::Adapter::NetHttpendSuppose we have these two models bound to your API:
# app/models/user.rbclassUserincludeHer::Modelcustom_get:popularend# app/models/post.rbclassPostincludeHer::Modelcustom_get:recent,:archivedendIn order to test them, we’ll have to stub the remote API requests. With RSpec, we can do this like so:
# spec/spec_helper.rbRSpec.configuredo |config|
config.include(Module.newdodefstub_api_for(klass)klass.use_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 initializerapi.setupurl: "http://api.example.com"do |c|
c.useHer::Middleware::FirstLevelParseJSONc.adapter(:test){ |s| yield(s)}endendend)endThen, in your tests, we can specify what (fake) HTTP requests will return:
# spec/models/user.rbdescribeUserdobeforedostub_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]}endenddescribe:populardosubject{User.popular}its(:length){should == 2}its(:errors){shouldbe_empty}endendWe can redefine the API for a model as many times as we want, like for more complex tests:
# spec/models/user.rbdescribePostdodescribe:recentdobeforedostub_api_for(Post)do |stub|
stub.get("/posts/recent"){ |env| [200,{},[{id: 1},{id: 2}].to_json]}endendsubject{Post.recent}its(:length){should == 2}its(:errors){shouldbe_empty}enddescribe:archiveddobeforedostub_api_for(Post)do |stub|
stub.get("/posts/archived"){ |env| [200,{},[{id: 1},{id: 2}].to_json]}endendsubject{Post.archived}its(:length){should == 2}its(:errors){shouldbe_empty}endendSee the UPGRADE.md for backward compatibility 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 concepts were written on a Saturday morning of April 2012 (first commit at 7am!).
The gem is currently maintained by @zacharywelch and @edtjones.
Yes please! Feel free to contribute and submit issues/pull requests on GitHub. There’s no such thing as a bad pull request — even if it’s for a typo, a small improvement to the code or the documentation!
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
- @stevschmid
- @prognostikos
- @dturnerTS
- @kritik
Her is © 2012-2013 Rémi Prévost and may be freely distributed under the MIT license. See the LICENSE file.

