Interact with remote REST services in an ActiveRecord-like manner.
Spyke basically rips off takes inspiration 😇 from Her, a gem which we sadly had to abandon as it gave us some performance problems and maintenance seemed to have gone stale.
We therefore made Spyke which adds a few fixes/features needed for our projects:
- Fast handling of even large amounts of JSON
- Proper support for scopes
- Ability to define custom URIs for associations
- ActiveRecord-like log output
- Handling of API-side validations
- Googlable name! :)
Add this line to your application's Gemfile:
gem 'spyke'
Spyke uses Faraday to handle requests and expects it to parse the response body into a hash in the following format:
{ data: { id: 1, name: 'Bob' }, metadata: {}, errors: {} }
So, for example for an API that returns JSON like this:
{ "result": { "id": 1, "name": "Bob" }, "extra": {}, "errors": {} }
...the simplest possible configuration that could work is something like this:
# config/initializers/spyke.rb
class JSONParser < Faraday::Response::Middleware
def parse(body)
json = MultiJson.load(body, symbolize_keys: true)
{
data: json[:result],
metadata: json[:extra],
errors: json[:errors]
}
end
end
Spyke::Base.connection = Faraday.new(url: 'https://api.com') do |c|
c.request :json
c.use JSONParser
c.adapter Faraday.default_adapter
end
Adding a class and inheriting from Spyke::Base
will allow you to interact with the remote service:
class User < Spyke::Base
has_many :posts
scope :active, -> { where(active: true) }
end
User.all
# => GET https://api.com/users
User.active
# => GET https://api.com/users?active=true
User.where(age: 3).active
# => GET https://api.com/users?active=true&age=3
user = User.find(3)
# => GET https://api.com/users/3
user.posts
# => find embedded in returned data or GET https://api.com/users/3/posts
user.update(name: 'Alice')
# => PUT https://api.com/users/3 - { user: { name: 'Alice' } }
user.destroy
# => DELETE https://api.com/users/3
User.create(name: 'Bob')
# => POST https://api.com/users - { user: { name: 'Bob' } }
You can specify custom URIs on both the class and association level.
Set uri to nil
for associations you only want to use data embedded
in the response and never call out to the API.
class User < Spyke::Base
uri 'people(/:id)' # id optional, both /people and /people/4 are valid
has_many :posts, uri: 'posts/for_user/:user_id' # user_id is required
has_one :image, uri: nil # only use embedded data
end
class Post < Spyke::Base
end
user = User.find(3) # => GET https://api.com/people/3
user.image # Will only use embedded data and never call out to api
user.posts # => GET https://api.com/posts/for_user/3
Post.find(4) # => GET https://api.com/posts/4
Custom request methods and the with
scope methods allow you to
perform requests for non-REST actions:
The .with
scope:
Post.with('posts/recent') # => GET https://api.com/posts/recent
Post.with(:recent) # => GET https://api.com/posts/recent
Post.with(:recent).where(status: 'draft') # => GET https://api.com/posts/recent?status=draft
Post.with(:recent).post # => POST https://api.com/posts/recent
Custom requests from instance:
Post.find(3).put(:publish) # => PUT https://api.com/posts/3/publish
Arbitrary requests (returns plain Result object):
Post.request(:post, 'posts/3/log', time: '12:00')
# => POST https://api.com/posts/3/log - { time: '12:00' }
Spyke expects errors to be formatted in the same way as the ActiveModel::Errors details hash, ie:
{ title: [{ error: 'blank'}, { error: 'too_short', count: 10 }]}
If the API you're using returns errors in a different format you can
remap it in Faraday to match the above. Doing this will allow you to
show errors returned from the server in forms and f.ex using
@post.errors.full_messages
just like ActiveRecord.
Should the API fail to connect or time out, a Spyke::ConnectionError
will be raised.
If you need to recover gracefully from connection problems, you can
either rescue that exception or use the with_fallback
feature:
# API is down
Article.all # => Spyke::ConnectionError
Article.with_fallback.all # => []
Article.find(1) # => Spyke::ConnectionError
Article.with_fallback.find(1) # => nil
article = Article.with_fallback(Article.new(title: "Dummy")).find(1)
article.title # => "Dummy"
Spyke, like Rails, by default wraps sent attributes in a root element, but this can be disabled or customized:
class Article < Spyke::Base
# Default
include_root_in_json true # { article: { title: ...} }
# Custom
include_root_in_json :post # { post: { title: ...} }
# Disabled
include_root_in_json false # { title: ... }
end
If you need to use different APIs, instead of configuring Spyke::Base
you can configure each class individually:
class Post < Spyke::Base
self.connection = Faraday.new(url: 'https://sashimi.com') do |faraday|
# middleware
end
end
When used with Rails, Spyke will automatically output helpful ActiveRecord-like messages to the main log:
Started GET "/posts" for 127.0.0.1 at 2014-12-01 14:31:20 +0000
Processing by PostsController#index as HTML
Parameters: {}
Spyke (40.3ms) GET https://api.com/posts [200]
Completed 200 OK in 75ms (Views: 64.6ms | Spyke: 40.3ms | ActiveRecord: 0ms)
For more examples of how Spyke can be used, check out fixtures.rb and the test suite.
If possible please take a look at the tests marked "wishlisted"! These are features/fixes I'd like to implement but haven't gotten around to doing yet :)