diff --git a/README.md b/README.md index 8f88186c..fd72b9a0 100644 --- a/README.md +++ b/README.md @@ -584,7 +584,7 @@ users = Users.all # GET "/users", response is { "users": [{ "id": 1, "fullname": "Lindsay Fünke" }, { "id": 1, "fullname": "Tobias Fünke" }] } ``` -#### JSON API support +#### JSON API support (experimental) 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: @@ -626,6 +626,119 @@ Her::API.setup url: 'https://my_awesome_json_api_service' do |c| end ``` +There is also partial support for relationships and compound documents. + +```json +{ + "data": { + { + "id": 1, + "type": "ballers", + "attributes": { name: "Roger Federer" }, + "relationships": { + "sponsors": { + "data": [ + { + "type": "sponsors", + "id": 1 + }, + { + "type": "sponsors", + "id": 2 + } + ] + }, + "country": { + "data": { + "type": "countries", + "id": 1 + } + } + } + } + }, + included: [ + { + type: 'sponsors', + id: 1, + attributes: { company: 'Nike' } + }, + { + type: 'sponsors', + id: 2, + attributes: { company: 'Rolex' } + }, + { + type: 'countries', + id: 1, + attributes: { name: 'Switzerland' } + } + ] +} +``` + +```ruby +class Baller + include Her::JsonApi::Model + + # will populate associations from included resources + has_many :sponsors + belongs_to :country + + # defaults to demodulized, pluralized class name, e.g. contributors + type :developers +end + +fed = Baller.find(1) +fed.sponsors.map(&:company) # => ["Nike", "Rolex"] +fed.country.name # => 'Switzerland' +``` + +However, relationships are ignored unless those resources are included. JSON API relationships +are built on the idea that a url can be passed to indicate the location of the resource. The dynamic +nature of this is something the client will look to support down the road. + +```json +{ + "data": { + { + "id": 1, + "type": "ballers", + "attributes": { "name": "Roger Federer", "country_id": 100}, + "relationships": { + "sponsors": { + "data": [ + { + "type": "sponsors", + "id": 1 + }, + { + "type": "sponsors", + "id": 2 + } + ] + }, + "country": { + "data": { + "type": "countries", + "id": 100 + } + } + } + } + } +} +``` + +Note, the presence of country_id is still depended on in order to fetch the belongs_to +association. Thus, given the preceding response which does not have included resources: + +```ruby +fed = Baller.find(1) # => GET "/ballers/1" +fed.sponsors # => GET "/ballers/1/sponsors" +fed.country # => GET "/ballers/1/country/100 +``` + ### Custom requests You can easily define custom requests for your models using `custom_get`, `custom_post`, etc. diff --git a/lib/her/middleware/json_api_parser.rb b/lib/her/middleware/json_api_parser.rb index a226b5c9..cbb66638 100644 --- a/lib/her/middleware/json_api_parser.rb +++ b/lib/her/middleware/json_api_parser.rb @@ -1,7 +1,7 @@ module Her module Middleware - # This middleware expects the resource/collection data to be contained in the `data` - # key of the JSON object + # This middleware requires the resource/collection + # data to be contained in the `data` key of the JSON object class JsonApiParser < ParseJSON # Parse the response body # @@ -11,13 +11,36 @@ class JsonApiParser < ParseJSON def parse(body) json = parse_json(body) + included = json.fetch(:included, []) + primary_data = json.fetch(:data, {}) + Array.wrap(primary_data).each do |resource| + resource_relationships = resource.delete(:relationships) { {} } + resource[:attributes].merge!(populate_relationships(resource_relationships, included.dup)) + end + { - :data => json[:data] || {}, + :data => primary_data || {}, :errors => json[:errors] || [], :metadata => json[:meta] || {}, } end + def populate_relationships(relationships, included) + return {} if included.empty? + {}.tap do |built| + relationships.each do |rel_name, linkage| + linkage_data = linkage.fetch(:data, {}) + built_relationship = if linkage_data.is_a? Array + linkage_data.map { |l| included.detect { |i| i.values_at(:id, :type) == l.values_at(:id, :type) } }.compact + else + included.detect { |i| i.values_at(:id, :type) == linkage_data.values_at(:id, :type) } + end + + built[rel_name] = built_relationship + end + end + end + # This method is triggered when the response has been received. It modifies # the value of `env[:body]`. # @@ -26,7 +49,11 @@ def parse(body) def on_complete(env) env[:body] = case env[:status] when 204 - parse('{}') + { + :data => {}, + :errors => [], + :metadata => {}, + } else parse(env[:body]) end diff --git a/spec/json_api/model_spec.rb b/spec/json_api/model_spec.rb index 14bcfd55..0dbbd901 100644 --- a/spec/json_api/model_spec.rb +++ b/spec/json_api/model_spec.rb @@ -5,8 +5,8 @@ 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| - [ + stub.get("/users/1") do |env| + [ 200, {}, { @@ -17,13 +17,13 @@ name: "Roger Federer", }, } - + }.to_json - ] + ] end - stub.get("/users") do |env| - [ + stub.get("/users") do |env| + [ 200, {}, { @@ -34,7 +34,7 @@ attributes: { name: "Roger Federer", }, - }, + }, { id: 2, type: 'users', @@ -44,7 +44,7 @@ } ] }.to_json - ] + ] end stub.post("/users", data: { @@ -53,7 +53,7 @@ name: "Jeremy Lin", }, }) do |env| - [ + [ 201, {}, { @@ -64,9 +64,9 @@ name: 'Jeremy Lin', }, } - + }.to_json - ] + ] end stub.patch("/users/1", data: { @@ -76,7 +76,7 @@ name: "Fed GOAT", }, }) do |env| - [ + [ 200, {}, { @@ -87,80 +87,219 @@ name: 'Fed GOAT', }, } - + }.to_json - ] + ] end stub.delete("/users/1") { |env| - [ 204, {}, {}, ] + [ 204, {}, {}, ] } - end + stub.get("/players") do |env| + [ + 200, + {}, + { + data: [ + { + id: 1, + type: 'players', + attributes: { name: "Roger Federer", }, + relationships: { + sponsors: { + data: [ + { + type: 'sponsors', + id: 1, + }, + { + type: 'sponsors', + id: 2, + } + ] + }, + racquet: { + data: { + type: 'racquets', + id: 1, + } + } + } + }, + { + id: 2, + type: 'players', + attributes: { name: "Kei Nishikori", }, + relationships: { + sponsors: { + data: [ + { + type: 'sponsors', + id: 2, + }, + { + type: 'sponsors', + id: 3, + } + ] + }, + racquet: { + data: { + type: 'racquets', + id: 2, + } + } + } + }, + { + id: 3, + type: 'players', + attributes: { name: 'Hubert Huang', racquet_id: nil }, + relationships: {} + }, + ], + included: [ + { + type: 'sponsors', + id: 1, + attributes: { + company: 'Nike', + } + }, + { + type: 'sponsors', + id: 2, + attributes: { + company: 'Wilson', + }, + }, + { + type: 'sponsors', + id: 3, + attributes: { + company: 'Uniqlo', + }, + }, + { + type: 'racquets', + id: 1, + attributes: { + name: 'Wilson Pro Staff', + }, + }, + { + type: 'racquets', + id: 2, + attributes: { + name: 'Wilson Steam', + } + }, + ] + }.to_json + ] + end + + stub.get("/players/3/sponsors") do |env| + [ + 200, + {}, + { data: [] }.to_json + ] + end + end end spawn_model("Foo::User", type: Her::JsonApi::Model) end - it 'allows configuration of type' do - spawn_model("Foo::Bar", type: Her::JsonApi::Model) do - type :foobars - end - - expect(Foo::Bar.instance_variable_get('@type')).to eql('foobars') - end + context 'simple jsonapi document' do + it 'allows configuration of type' do + spawn_model("Foo::Bar", type: Her::JsonApi::Model) do + type :foobars + end - it 'finds models by id' do - user = Foo::User.find(1) - expect(user.attributes).to eql( - 'id' => 1, - 'name' => 'Roger Federer', - ) - end + expect(Foo::Bar.instance_variable_get('@type')).to eql('foobars') + end - it 'finds a collection of models' do - users = Foo::User.all - expect(users.map(&:attributes)).to match_array([ - { + it 'finds models by id' do + user = Foo::User.find(1) + expect(user.attributes).to eql( 'id' => 1, 'name' => 'Roger Federer', - }, - { - 'id' => 2, - 'name' => 'Kei Nishikori', - } - ]) - end + ) + 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 '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 '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 '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 'destroys a Foo::User' do - user = Foo::User.find(1) - expect(user.destroy).to be_destroyed + 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 - 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" + context 'compound document' do + before do + spawn_model("Foo::Sponsor", type: Her::JsonApi::Model) + spawn_model("Foo::Racquet", type: Her::JsonApi::Model) + spawn_model("Foo::Player", type: Her::JsonApi::Model) do + has_many :sponsors + belongs_to :racquet end end + + it 'parses included documents into object if relationship specifies a resource linkage' do + players = Foo::Player.all.to_a + fed = players.detect { |p| p.name == 'Roger Federer' } + expect(fed.sponsors.map(&:company)).to match_array ['Nike', 'Wilson'] + expect(fed.racquet.name).to eq 'Wilson Pro Staff' + + kei = players.detect { |p| p.name == 'Kei Nishikori' } + expect(kei.sponsors.map(&:company)).to match_array ['Uniqlo', 'Wilson'] + expect(kei.racquet.name).to eq 'Wilson Steam' + + hubert = players.detect { |p| p.name == 'Hubert Huang' } + expect(hubert.sponsors).to eq [] + expect(hubert.racquet).to be_nil + end end end diff --git a/spec/json_api/relationships_spec.rb b/spec/json_api/relationships_spec.rb new file mode 100644 index 00000000..2cb13669 --- /dev/null +++ b/spec/json_api/relationships_spec.rb @@ -0,0 +1,220 @@ +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('/ballers') do |env| + [ + 200, + {}, + { + data: [ + { + id: 1, + type: 'ballers', + attributes: { name: "Jeremy Lin", }, + relationships: { + teammates: { + data: [ + { + type: 'teammates', + id: 1, + }, + { + type: 'teammates', + id: 2, + } + ] + }, + team: { + data: { + type: 'teams', + id: 1, + } + } + } + }, + { + id: 2, + type: 'ballers', + attributes: { name: 'Carmelo Anthony' }, + }, + ], + }.to_json, + ] + end + + stub.get('ballers/1/teammates') do + [ 200, {}, { data: [] }.to_json ] + end + + stub.get('ballers/2/teammates') do + [ 200, {}, { data: [] }.to_json ] + end + + stub.get("/players") do |env| + [ + 200, + {}, + { + data: [ + { + id: 1, + type: 'players', + attributes: { name: "Roger Federer", }, + relationships: { + sponsors: { + data: [ + { + type: 'sponsors', + id: 1, + }, + { + type: 'sponsors', + id: 2, + } + ] + }, + racquet: { + data: { + type: 'racquets', + id: 1, + } + } + } + }, + { + id: 2, + type: 'players', + attributes: { name: "Kei Nishikori", }, + relationships: { + sponsors: { + data: [ + { + type: 'sponsors', + id: 2, + }, + { + type: 'sponsors', + id: 3, + } + ] + }, + racquet: { + data: { + type: 'racquets', + id: 2, + } + } + } + }, + { + id: 3, + type: 'players', + attributes: { name: 'Hubert Huang', racquet_id: nil }, + relationships: {} + }, + ], + included: [ + { + type: 'sponsors', + id: 1, + attributes: { + company: 'Nike', + } + }, + { + type: 'sponsors', + id: 2, + attributes: { + company: 'Wilson', + }, + }, + { + type: 'sponsors', + id: 3, + attributes: { + company: 'Uniqlo', + }, + }, + { + type: 'racquets', + id: 1, + attributes: { + name: 'Wilson Pro Staff', + }, + }, + { + type: 'racquets', + id: 2, + attributes: { + name: 'Wilson Steam', + } + }, + ] + }.to_json + ] + end + + stub.get("/players/3/sponsors") do |env| + [ + 200, + {}, + { data: [] }.to_json + ] + end + end + end + end + + context 'document with relationships' do + before do + spawn_model("Foo::Teammate", type: Her::JsonApi::Model) + spawn_model("Foo::Team", type: Her::JsonApi::Model) + spawn_model("Foo::Baller", type: Her::JsonApi::Model) do + has_many :teammates + belongs_to :team + end + end + + it 'parses included documents into object if relationship specifies a resource linkage' do + players = Foo::Baller.all + lin = players.detect { |p| p.name == 'Jeremy Lin' } + expect(lin.team).to be_nil + expect(lin.teammates).to be_empty + + melo = players.detect { |p| p.name == 'Carmelo Anthony' } + expect(melo.teammates).to eq [] + expect(melo.team).to be_nil + end + end + + context 'compound document' do + before do + spawn_model("Foo::Sponsor", type: Her::JsonApi::Model) + spawn_model("Foo::Racquet", type: Her::JsonApi::Model) + spawn_model("Foo::Player", type: Her::JsonApi::Model) do + has_many :sponsors + belongs_to :racquet + end + end + + it 'parses included documents into object if relationship specifies a resource linkage' do + players = Foo::Player.all.to_a + fed = players.detect { |p| p.name == 'Roger Federer' } + expect(fed.sponsors.map(&:company)).to match_array ['Nike', 'Wilson'] + expect(fed.racquet.name).to eq 'Wilson Pro Staff' + + kei = players.detect { |p| p.name == 'Kei Nishikori' } + expect(kei.sponsors.map(&:company)).to match_array ['Uniqlo', 'Wilson'] + expect(kei.racquet.name).to eq 'Wilson Steam' + + hubert = players.detect { |p| p.name == 'Hubert Huang' } + expect(hubert.sponsors).to eq [] + expect(hubert.racquet).to be_nil + end + end +end +