Skip to content

Commit

Permalink
Merge pull request #347 from hubert/feature/json-api-support
Browse files Browse the repository at this point in the history
Feature/json api support
  • Loading branch information
hubert committed Jun 30, 2015
2 parents 292cbc8 + 4cb27c3 commit 0541bd4
Show file tree
Hide file tree
Showing 8 changed files with 321 additions and 12 deletions.
42 changes: 33 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -586,20 +586,44 @@ users = Users.all

#### JSON API support

If the API returns data in the [JSON API format](http://jsonapi.org/) you need
to configure Her as follows:
To consume a JSON API 1.0 compliant service, it must return data in accordance with the [JSON API spec](http://jsonapi.org/). The general format
of the data is as follows:

```json
{ "data": {
"type": "developers",
"id": "6ab79c8c-ec5a-4426-ad38-8763bbede5a7",
"attributes": {
"language": "ruby",
"name": "avdi grimm",
}
}
```

Then to setup your models:

```ruby
class User
include Her::Model
parse_root_in_json true, format: :json_api
class Contributor
include Her::JsonApi::Model

# defaults to demodulized, pluralized class name, e.g. contributors
type :developers
end
```

user = Users.find(1)
# GET "/users/1", response is { "users": [{ "id": 1, "fullname": "Lindsay Fünke" }] }
Finally, you'll need to use the included JsonApiParser Her middleware:

users = Users.all
# GET "/users", response is { "users": [{ "id": 1, "fullname": "Lindsay Fünke" }, { "id": 2, "fullname": "Tobias Fünke" }] }
```
Her::API.setup url: 'https://my_awesome_json_api_service' do |c|
# Request
c.use FaradayMiddleware::EncodeJson

# Response
c.use Her::Middleware::JsonApiParser

# Adapter
c.use Faraday::Adapter::NetHttp
end
```

### Custom requests
Expand Down
3 changes: 3 additions & 0 deletions lib/her.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@
require "her/collection"

module Her
module JsonApi
autoload :Model, 'her/json_api/model'
end
end
46 changes: 46 additions & 0 deletions lib/her/json_api/model.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
module Her
module JsonApi
module Model

def self.included(klass)
klass.class_eval do
include Her::Model

[:parse_root_in_json, :include_root_in_json, :root_element, :primary_key].each do |method|
define_method method do |*args|
raise NoMethodError, "Her::JsonApi::Model does not support the #{method} configuration option"
end
end

method_for :update, :patch

@type = name.demodulize.tableize

def self.parse(data)
data.fetch(:attributes).merge(data.slice(:id))
end

def self.to_params(attributes, changes={})
request_data = { type: @type }.tap { |request_body|
attrs = attributes.dup.symbolize_keys.tap { |filtered_attributes|
if her_api.options[:send_only_modified_attributes]
filtered_attributes = changes.symbolize_keys.keys.inject({}) do |hash, attribute|
hash[attribute] = filtered_attributes[attribute]
hash
end
end
}
request_body[:id] = attrs.delete(:id) if attrs[:id]
request_body[:attributes] = attrs
}
{ data: request_data }
end

def self.type(type_name)
@type = type_name.to_s
end
end
end
end
end
end
2 changes: 2 additions & 0 deletions lib/her/middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@
module Her
module Middleware
DefaultParseJSON = FirstLevelParseJSON

autoload :JsonApiParser, 'her/middleware/json_api_parser'
end
end
36 changes: 36 additions & 0 deletions lib/her/middleware/json_api_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
module Her
module Middleware
# This middleware expects the resource/collection data to be contained in the `data`
# key of the JSON object
class JsonApiParser < ParseJSON
# Parse the response body
#
# @param [String] body The response body
# @return [Mixed] the parsed response
# @private
def parse(body)
json = parse_json(body)

{
:data => json[:data] || {},
:errors => json[:errors] || [],
:metadata => json[:meta] || {},
}
end

# This method is triggered when the response has been received. It modifies
# the value of `env[:body]`.
#
# @param [Hash] env The response environment
# @private
def on_complete(env)
env[:body] = case env[:status]
when 204
parse('{}')
else
parse(env[:body])
end
end
end
end
end
166 changes: 166 additions & 0 deletions spec/json_api/model_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
require 'spec_helper'

describe Her::JsonApi::Model do
before do
Her::API.setup :url => "https://api.example.com" do |connection|
connection.use Her::Middleware::JsonApiParser
connection.adapter :test do |stub|
stub.get("/users/1") do |env|
[
200,
{},
{
data: {
id: 1,
type: 'users',
attributes: {
name: "Roger Federer",
},
}

}.to_json
]
end

stub.get("/users") do |env|
[
200,
{},
{
data: [
{
id: 1,
type: 'users',
attributes: {
name: "Roger Federer",
},
},
{
id: 2,
type: 'users',
attributes: {
name: "Kei Nishikori",
},
}
]
}.to_json
]
end

stub.post("/users", data: {
type: 'users',
attributes: {
name: "Jeremy Lin",
},
}) do |env|
[
201,
{},
{
data: {
id: 3,
type: 'users',
attributes: {
name: 'Jeremy Lin',
},
}

}.to_json
]
end

stub.patch("/users/1", data: {
type: 'users',
id: 1,
attributes: {
name: "Fed GOAT",
},
}) do |env|
[
200,
{},
{
data: {
id: 1,
type: 'users',
attributes: {
name: 'Fed GOAT',
},
}

}.to_json
]
end

stub.delete("/users/1") { |env|
[ 204, {}, {}, ]
}
end

end

spawn_model("Foo::User", Her::JsonApi::Model)
end

it 'allows configuration of type' do
spawn_model("Foo::Bar", Her::JsonApi::Model) do
type :foobars
end

expect(Foo::Bar.instance_variable_get('@type')).to eql('foobars')
end

it 'finds models by id' do
user = Foo::User.find(1)
expect(user.attributes).to eql(
'id' => 1,
'name' => 'Roger Federer',
)
end

it 'finds a collection of models' do
users = Foo::User.all
expect(users.map(&:attributes)).to match_array([
{
'id' => 1,
'name' => 'Roger Federer',
},
{
'id' => 2,
'name' => 'Kei Nishikori',
}
])
end

it 'creates a Foo::User' do
user = Foo::User.new(name: 'Jeremy Lin')
user.save
expect(user.attributes).to eql(
'id' => 3,
'name' => 'Jeremy Lin',
)
end

it 'updates a Foo::User' do
user = Foo::User.find(1)
user.name = 'Fed GOAT'
user.save
expect(user.attributes).to eql(
'id' => 1,
'name' => 'Fed GOAT',
)
end

it 'destroys a Foo::User' do
user = Foo::User.find(1)
expect(user.destroy).to be_destroyed
end

context 'undefined methods' do
it 'removes methods that are not compatible with json api' do
[:parse_root_in_json, :include_root_in_json, :root_element, :primary_key].each do |method|
expect { Foo::User.new.send(method, :foo) }.to raise_error NoMethodError, "Her::JsonApi::Model does not support the #{method} configuration option"
end
end
end
end
32 changes: 32 additions & 0 deletions spec/middleware/json_api_parser_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# encoding: utf-8
require "spec_helper"

describe Her::Middleware::JsonApiParser do
subject { described_class.new }

context "with valid JSON body" do
let(:body) { '{"data": {"type": "foo", "id": "bar", "attributes": {"baz": "qux"} }, "meta": {"api": "json api"} }' }
let(:env) { { body: body } }

it "parses body as json" do
subject.on_complete(env)
env.fetch(:body).tap do |json|
expect(json[:data]).to eql(
:type => "foo",
:id => "bar",
:attributes => { :baz => "qux" }
)
expect(json[:errors]).to eql([])
expect(json[:metadata]).to eql(:api => "json api")
end
end
end

#context "with invalid JSON body" do
# let(:body) { '"foo"' }
# it 'ensures that invalid JSON throws an exception' do
# expect { subject.parse(body) }.to raise_error(Her::Errors::ParseError, 'Response from the API must behave like a Hash or an Array (last JSON response was "\"foo\"")')
# end
#end

end
6 changes: 3 additions & 3 deletions spec/support/macros/model_macros.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@ module Testing
module Macros
module ModelMacros
# Create a class and automatically inject Her::Model into it
def spawn_model(klass, &block)
def spawn_model(klass, model_type=Her::Model, &block)
if klass =~ /::/
base, submodel = klass.split(/::/).map{ |s| s.to_sym }
Object.const_set(base, Module.new) unless Object.const_defined?(base)
Object.const_get(base).module_eval do
remove_const submodel if constants.map(&:to_sym).include?(submodel)
submodel = const_set(submodel, Class.new)
submodel.send(:include, Her::Model)
submodel.send(:include, model_type)
submodel.class_eval(&block) if block_given?
end

@spawned_models << base
else
Object.instance_eval { remove_const klass } if Object.const_defined?(klass)
Object.const_set(klass, Class.new).send(:include, Her::Model)
Object.const_set(klass, Class.new).send(:include, model_type)
Object.const_get(klass).class_eval(&block) if block_given?

@spawned_models << klass.to_sym
Expand Down

0 comments on commit 0541bd4

Please sign in to comment.