├── .rspec ├── .yardopts ├── .gitignore ├── lib ├── her │ ├── version.rb │ ├── collection.rb │ ├── middleware.rb │ ├── middleware │ │ ├── accept_json.rb │ │ ├── parse_json.rb │ │ ├── json_api_parser.rb │ │ ├── second_level_parse_json.rb │ │ └── first_level_parse_json.rb │ ├── errors.rb │ ├── model │ │ ├── base.rb │ │ ├── associations │ │ │ ├── association_proxy.rb │ │ │ ├── has_one_association.rb │ │ │ ├── belongs_to_association.rb │ │ │ ├── has_many_association.rb │ │ │ └── association.rb │ │ ├── nested_attributes.rb │ │ ├── deprecated_methods.rb │ │ ├── introspection.rb │ │ ├── paths.rb │ │ ├── http.rb │ │ ├── associations.rb │ │ ├── relation.rb │ │ ├── parse.rb │ │ ├── attributes.rb │ │ └── orm.rb │ ├── json_api │ │ └── model.rb │ ├── model.rb │ └── api.rb └── her.rb ├── spec ├── support │ ├── extensions │ │ ├── array.rb │ │ └── hash.rb │ └── macros │ │ ├── her_macros.rb │ │ ├── request_macros.rb │ │ └── model_macros.rb ├── middleware │ ├── accept_json_spec.rb │ ├── json_api_parser_spec.rb │ ├── second_level_parse_json_spec.rb │ └── first_level_parse_json_spec.rb ├── spec_helper.rb ├── model │ ├── associations │ │ └── association_proxy_spec.rb │ ├── validations_spec.rb │ ├── introspection_spec.rb │ ├── dirty_spec.rb │ ├── callbacks_spec.rb │ ├── nested_attributes_spec.rb │ ├── http_spec.rb │ ├── relation_spec.rb │ ├── parse_spec.rb │ ├── attributes_spec.rb │ ├── paths_spec.rb │ └── orm_spec.rb ├── collection_spec.rb ├── model_spec.rb ├── json_api │ └── model_spec.rb └── api_spec.rb ├── gemfiles ├── Gemfile.activemodel-4.0 ├── Gemfile.activemodel-4.1 ├── Gemfile.activemodel-4.2 └── Gemfile.activemodel-3.2.x ├── Rakefile ├── Gemfile ├── .travis.yml ├── LICENSE ├── her.gemspec ├── CONTRIBUTING.md └── UPGRADE.md /.rspec: -------------------------------------------------------------------------------- 1 | --colour --format=documentation 2 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --protected 2 | --no-private 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /Gemfile.lock 2 | /pkg 3 | /tmp 4 | /coverage 5 | -------------------------------------------------------------------------------- /lib/her/version.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | VERSION = "0.8.6" 3 | end 4 | -------------------------------------------------------------------------------- /spec/support/extensions/array.rb: -------------------------------------------------------------------------------- 1 | class Array 2 | def to_json 3 | MultiJson.dump(self) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/extensions/hash.rb: -------------------------------------------------------------------------------- 1 | class Hash 2 | def to_json 3 | MultiJson.dump(self) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.activemodel-4.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec :path => "../" 4 | 5 | gem 'activemodel', '~> 4.0.0' 6 | gem 'activesupport', '~> 4.0.0' 7 | gem 'faraday', '~> 0.8.9' 8 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.activemodel-4.1: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec :path => "../" 4 | 5 | gem 'activemodel', '~> 4.1.0' 6 | gem 'activesupport', '~> 4.1.0' 7 | gem 'faraday', '~> 0.8.9' 8 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.activemodel-4.2: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec :path => "../" 4 | 5 | gem 'activemodel', '~> 4.2.0' 6 | gem 'activesupport', '~> 4.2.0' 7 | gem 'faraday', '~> 0.8.9' 8 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.activemodel-3.2.x: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec :path => "../" 4 | 5 | gem 'activemodel', '~> 3.2.0' 6 | gem 'activesupport', '~> 3.2.0' 7 | gem 'faraday', '~> 0.8.9' 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler" 2 | require "rake" 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | task :default => :spec 7 | 8 | desc "Run all specs" 9 | RSpec::Core::RakeTask.new(:spec) do |task| 10 | task.pattern = "spec/**/*_spec.rb" 11 | end 12 | -------------------------------------------------------------------------------- /lib/her/collection.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | class Collection < ::Array 3 | attr_reader :metadata, :errors 4 | 5 | # @private 6 | def initialize(items=[], metadata={}, errors={}) 7 | super(items) 8 | @metadata = metadata 9 | @errors = errors 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/middleware/accept_json_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require "spec_helper" 3 | 4 | describe Her::Middleware::AcceptJSON do 5 | it "adds an Accept header" do 6 | described_class.new.add_header({}).tap do |headers| 7 | expect(headers["Accept"]).to eq("application/json") 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | 4 | if RbConfig::CONFIG['RUBY_PROGRAM_VERSION'] && RbConfig::CONFIG['RUBY_PROGRAM_VERSION'] >= '1.9.3' 5 | gem 'activemodel', '>= 3.2.0' 6 | gem 'activesupport', '>= 3.2.0' 7 | else 8 | gem 'activemodel', '~> 3.2.0' 9 | gem 'activesupport', '~> 3.2.0' 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/macros/her_macros.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Testing 3 | module Macros 4 | def ok!(body) 5 | [200, {}, body.to_json] 6 | end 7 | 8 | def error!(body) 9 | [400, {}, body.to_json] 10 | end 11 | 12 | def params(env) 13 | Faraday::Utils.parse_query(env[:body]) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/her/middleware.rb: -------------------------------------------------------------------------------- 1 | require "her/middleware/parse_json" 2 | require "her/middleware/first_level_parse_json" 3 | require "her/middleware/second_level_parse_json" 4 | require "her/middleware/accept_json" 5 | 6 | module Her 7 | module Middleware 8 | DefaultParseJSON = FirstLevelParseJSON 9 | 10 | autoload :JsonApiParser, 'her/middleware/json_api_parser' 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | sudo: false 4 | 5 | rvm: 6 | - 2.4.1 7 | - 2.3.1 8 | - 2.2.2 9 | - 2.1.6 10 | - 2.0.0 11 | - 1.9.3 12 | 13 | gemfile: 14 | - gemfiles/Gemfile.activemodel-4.2 15 | - gemfiles/Gemfile.activemodel-4.1 16 | - gemfiles/Gemfile.activemodel-4.0 17 | - gemfiles/Gemfile.activemodel-3.2.x 18 | 19 | script: "echo 'COME ON!' && bundle exec rake spec" 20 | -------------------------------------------------------------------------------- /lib/her.rb: -------------------------------------------------------------------------------- 1 | require "her/version" 2 | 3 | require "multi_json" 4 | require "faraday" 5 | require "active_support" 6 | require "active_support/inflector" 7 | require "active_support/core_ext/hash" 8 | 9 | require "her/model" 10 | require "her/api" 11 | require "her/middleware" 12 | require "her/errors" 13 | require "her/collection" 14 | 15 | module Her 16 | module JsonApi 17 | autoload :Model, 'her/json_api/model' 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/her/middleware/accept_json.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Middleware 3 | # This middleware adds a "Accept: application/json" HTTP header 4 | class AcceptJSON < Faraday::Middleware 5 | # @private 6 | def add_header(headers) 7 | headers.merge! "Accept" => "application/json" 8 | end 9 | 10 | # @private 11 | def call(env) 12 | add_header(env[:request_headers]) 13 | @app.call(env) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/her/middleware/parse_json.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Middleware 3 | class ParseJSON < Faraday::Response::Middleware 4 | # @private 5 | def parse_json(body = nil) 6 | body = '{}' if body.blank? 7 | message = "Response from the API must behave like a Hash or an Array (last JSON response was #{body.inspect})" 8 | 9 | json = begin 10 | MultiJson.load(body, :symbolize_keys => true) 11 | rescue MultiJson::LoadError 12 | raise Her::Errors::ParseError, message 13 | end 14 | 15 | raise Her::Errors::ParseError, message unless json.is_a?(Hash) or json.is_a?(Array) 16 | 17 | json 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/support/macros/request_macros.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Testing 3 | module Macros 4 | module RequestMacros 5 | def ok!(body) 6 | [200, {}, body.to_json] 7 | end 8 | 9 | def error!(body) 10 | [400, {}, body.to_json] 11 | end 12 | 13 | def params(env) 14 | @params ||= begin 15 | parsed_query = Faraday::Utils.parse_nested_query(env[:body]) 16 | 17 | if parsed_query 18 | parsed_query.with_indifferent_access.merge(env[:params]) 19 | else 20 | env[:params].with_indifferent_access 21 | end 22 | end 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/her/errors.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Errors 3 | class PathError < StandardError 4 | attr_reader :missing_parameter 5 | 6 | def initialize(message, missing_parameter=nil) 7 | super(message) 8 | @missing_parameter = missing_parameter 9 | end 10 | end 11 | 12 | class AssociationUnknownError < StandardError 13 | end 14 | 15 | class ParseError < StandardError 16 | end 17 | 18 | class ResourceInvalid < StandardError 19 | attr_reader :resource 20 | def initialize(resource) 21 | @resource = resource 22 | errors = @resource.response_errors.join(", ") 23 | super("Remote validation failed: #{errors}") 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.join(File.dirname(__FILE__), "..", "lib") 2 | 3 | require "rspec" 4 | require "her" 5 | 6 | # Require everything in `spec/support` 7 | Dir[File.expand_path("../../spec/support/**/*.rb", __FILE__)].map(&method(:require)) 8 | 9 | # Remove ActiveModel deprecation message 10 | I18n.enforce_available_locales = false 11 | 12 | RSpec.configure do |config| 13 | config.include Her::Testing::Macros::ModelMacros 14 | config.include Her::Testing::Macros::RequestMacros 15 | 16 | config.before :each do 17 | @spawned_models = [] 18 | end 19 | 20 | config.after :each do 21 | @spawned_models.each do |model| 22 | Object.instance_eval { remove_const model } if Object.const_defined?(model) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/model/associations/association_proxy_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require "spec_helper" 3 | 4 | describe Her::Model::Associations::AssociationProxy do 5 | describe "proxy assignment methods" do 6 | before do 7 | Her::API.setup url: "https://api.example.com" do |builder| 8 | builder.use Her::Middleware::FirstLevelParseJSON 9 | builder.use Faraday::Request::UrlEncoded 10 | builder.adapter :test do |stub| 11 | stub.get("/users/1") { [200, {}, { id: 1, name: "Tobias Fünke" }.to_json] } 12 | stub.get("/users/1/fish") { [200, {}, { id: 1, name: "Tobias's Fish" }.to_json] } 13 | end 14 | end 15 | spawn_model "User" do 16 | has_one :fish 17 | end 18 | spawn_model "Fish" 19 | end 20 | 21 | subject { User.find(1) } 22 | 23 | it "should assign value" do 24 | subject.fish.name = "Fishy" 25 | expect(subject.fish.name).to eq "Fishy" 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/her/model/base.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Model 3 | # This module includes basic functionnality to Her::Model 4 | module Base 5 | extend ActiveSupport::Concern 6 | 7 | # Returns true if attribute_name is 8 | # * in resource attributes 9 | # * an association 10 | # 11 | # @private 12 | def has_key?(attribute_name) 13 | has_attribute?(attribute_name) || 14 | has_association?(attribute_name) 15 | end 16 | 17 | # Returns 18 | # * the value of the attribute_name attribute if it's in orm data 19 | # * the resource/collection corrsponding to attribute_name if it's an association 20 | # 21 | # @private 22 | def [](attribute_name) 23 | get_attribute(attribute_name) || 24 | get_association(attribute_name) 25 | end 26 | 27 | # @private 28 | def singularized_resource_name 29 | self.class.name.split('::').last.tableize.singularize 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2015 Rémi Prévost 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /lib/her/middleware/json_api_parser.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Middleware 3 | # This middleware expects the resource/collection data to be contained in the `data` 4 | # key of the JSON object 5 | class JsonApiParser < ParseJSON 6 | # Parse the response body 7 | # 8 | # @param [String] body The response body 9 | # @return [Mixed] the parsed response 10 | # @private 11 | def parse(body) 12 | json = parse_json(body) 13 | 14 | { 15 | :data => json[:data] || {}, 16 | :errors => json[:errors] || [], 17 | :metadata => json[:meta] || {}, 18 | } 19 | end 20 | 21 | # This method is triggered when the response has been received. It modifies 22 | # the value of `env[:body]`. 23 | # 24 | # @param [Hash] env The response environment 25 | # @private 26 | def on_complete(env) 27 | env[:body] = case env[:status] 28 | when 204 29 | parse('{}') 30 | else 31 | parse(env[:body]) 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/her/middleware/second_level_parse_json.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Middleware 3 | # This middleware expects the resource/collection data to be contained in the `data` 4 | # key of the JSON object 5 | class SecondLevelParseJSON < ParseJSON 6 | # Parse the response body 7 | # 8 | # @param [String] body The response body 9 | # @return [Mixed] the parsed response 10 | # @private 11 | def parse(body) 12 | json = parse_json(body) 13 | 14 | { 15 | :data => json[:data], 16 | :errors => json[:errors], 17 | :metadata => json[:metadata] 18 | } 19 | end 20 | 21 | # This method is triggered when the response has been received. It modifies 22 | # the value of `env[:body]`. 23 | # 24 | # @param [Hash] env The response environment 25 | # @private 26 | def on_complete(env) 27 | env[:body] = case env[:status] 28 | when 204 29 | parse('{}') 30 | else 31 | parse(env[:body]) 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/her/middleware/first_level_parse_json.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Middleware 3 | # This middleware treat the received first-level JSON structure as the resource data. 4 | class FirstLevelParseJSON < ParseJSON 5 | # Parse the response body 6 | # 7 | # @param [String] body The response body 8 | # @return [Mixed] the parsed response 9 | # @private 10 | def parse(body) 11 | json = parse_json(body) 12 | errors = json.delete(:errors) || {} 13 | metadata = json.delete(:metadata) || {} 14 | { 15 | :data => json, 16 | :errors => errors, 17 | :metadata => metadata 18 | } 19 | end 20 | 21 | # This method is triggered when the response has been received. It modifies 22 | # the value of `env[:body]`. 23 | # 24 | # @param [Hash] env The response environment 25 | # @private 26 | def on_complete(env) 27 | env[:body] = case env[:status] 28 | when 204 29 | parse('{}') 30 | else 31 | parse(env[:body]) 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/middleware/json_api_parser_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require "spec_helper" 3 | 4 | describe Her::Middleware::JsonApiParser do 5 | subject { described_class.new } 6 | 7 | context "with valid JSON body" do 8 | let(:body) { '{"data": {"type": "foo", "id": "bar", "attributes": {"baz": "qux"} }, "meta": {"api": "json api"} }' } 9 | let(:env) { { body: body } } 10 | 11 | it "parses body as json" do 12 | subject.on_complete(env) 13 | env.fetch(:body).tap do |json| 14 | expect(json[:data]).to eql( 15 | type: "foo", 16 | id: "bar", 17 | attributes: { baz: "qux" } 18 | ) 19 | expect(json[:errors]).to eql([]) 20 | expect(json[:metadata]).to eql(api: "json api") 21 | end 22 | end 23 | end 24 | 25 | # context "with invalid JSON body" do 26 | # let(:body) { '"foo"' } 27 | # it 'ensures that invalid JSON throws an exception' do 28 | # 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\"")') 29 | # end 30 | # end 31 | end 32 | -------------------------------------------------------------------------------- /spec/collection_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Her::Collection do 4 | let(:items) { [1, 2, 3, 4] } 5 | let(:metadata) { { name: "Testname" } } 6 | let(:errors) { { name: ["not_present"] } } 7 | 8 | describe "#new" do 9 | context "without parameters" do 10 | subject { Her::Collection.new } 11 | 12 | it { is_expected.to eq([]) } 13 | 14 | describe "#metadata" do 15 | subject { super().metadata } 16 | it { is_expected.to eq({}) } 17 | end 18 | 19 | describe "#errors" do 20 | subject { super().errors } 21 | it { is_expected.to eq({}) } 22 | end 23 | end 24 | 25 | context "with parameters" do 26 | subject { Her::Collection.new(items, metadata, errors) } 27 | 28 | it { is_expected.to eq([1, 2, 3, 4]) } 29 | 30 | describe "#metadata" do 31 | subject { super().metadata } 32 | it { is_expected.to eq(name: "Testname") } 33 | end 34 | 35 | describe "#errors" do 36 | subject { super().errors } 37 | it { is_expected.to eq(name: ["not_present"]) } 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/middleware/second_level_parse_json_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require "spec_helper" 3 | 4 | describe Her::Middleware::SecondLevelParseJSON do 5 | subject { described_class.new } 6 | 7 | context "with valid JSON body" do 8 | let(:body) { "{\"data\": 1, \"errors\": 2, \"metadata\": 3}" } 9 | it "parses body as json" do 10 | subject.parse(body).tap do |json| 11 | expect(json[:data]).to eq(1) 12 | expect(json[:errors]).to eq(2) 13 | expect(json[:metadata]).to eq(3) 14 | end 15 | end 16 | 17 | it "parses :body key as json in the env hash" do 18 | env = { body: body } 19 | subject.on_complete(env) 20 | env[:body].tap do |json| 21 | expect(json[:data]).to eq(1) 22 | expect(json[:errors]).to eq(2) 23 | expect(json[:metadata]).to eq(3) 24 | end 25 | end 26 | end 27 | 28 | context "with invalid JSON body" do 29 | let(:body) { '"foo"' } 30 | it "ensures that invalid JSON throws an exception" do 31 | 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\"")') 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /her.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "her/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "her" 7 | s.version = Her::VERSION 8 | s.authors = ["Rémi Prévost"] 9 | s.email = ["remi@exomel.com"] 10 | s.homepage = "http://her-rb.org" 11 | s.license = "MIT" 12 | s.summary = "A simple Representational State Transfer-based Hypertext Transfer Protocol-powered Object Relational Mapper. Her?" 13 | s.description = "Her is an ORM that maps REST resources and collections to Ruby objects" 14 | 15 | s.files = `git ls-files`.split("\n") 16 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 17 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 18 | s.require_paths = ["lib"] 19 | 20 | s.add_development_dependency "rake", "~> 10.0" 21 | s.add_development_dependency "rspec", "~> 3.5" 22 | s.add_development_dependency "json", "~> 1.8" 23 | 24 | s.add_runtime_dependency "activemodel", ">= 3.0.0", "<= 6.0.0" 25 | s.add_runtime_dependency "activesupport", ">= 3.0.0", "<= 6.0.0" 26 | s.add_runtime_dependency "faraday", ">= 0.8", "< 1.0" 27 | s.add_runtime_dependency "multi_json", "~> 1.7" 28 | end 29 | -------------------------------------------------------------------------------- /spec/model/validations_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.join(File.dirname(__FILE__), "../spec_helper.rb") 3 | 4 | describe "Her::Model and ActiveModel::Validations" do 5 | context "validating attributes" do 6 | before do 7 | spawn_model "Foo::User" do 8 | attributes :fullname, :email 9 | validates_presence_of :fullname 10 | validates_presence_of :email 11 | end 12 | end 13 | 14 | it "validates attributes when calling #valid?" do 15 | user = Foo::User.new 16 | expect(user).not_to be_valid 17 | expect(user.errors.full_messages).to include("Fullname can't be blank") 18 | expect(user.errors.full_messages).to include("Email can't be blank") 19 | user.fullname = "Tobias Fünke" 20 | user.email = "tobias@bluthcompany.com" 21 | expect(user).to be_valid 22 | end 23 | end 24 | 25 | context "handling server errors" do 26 | before do 27 | spawn_model("Foo::Model") do 28 | def errors 29 | @response_errors 30 | end 31 | end 32 | 33 | class User < Foo::Model; end 34 | @spawned_models << :User 35 | end 36 | 37 | it "validates attributes when calling #valid?" do 38 | user = User.new(_errors: ["Email cannot be blank"]) 39 | expect(user.errors).to include("Email cannot be blank") 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/support/macros/model_macros.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Testing 3 | module Macros 4 | module ModelMacros 5 | # Create a class and automatically inject Her::Model into it 6 | def spawn_model(klass, options = {}, &block) 7 | super_class = options[:super_class] 8 | model_type = options[:type] || Her::Model 9 | new_class = if super_class 10 | Class.new(super_class) 11 | else 12 | Class.new 13 | end 14 | if klass =~ /::/ 15 | base, submodel = klass.split(/::/).map(&:to_sym) 16 | Object.const_set(base, Module.new) unless Object.const_defined?(base) 17 | Object.const_get(base).module_eval do 18 | remove_const submodel if constants.map(&:to_sym).include?(submodel) 19 | submodel = const_set(submodel, new_class) 20 | submodel.send(:include, model_type) 21 | submodel.class_eval(&block) if block_given? 22 | end 23 | 24 | @spawned_models << base 25 | else 26 | Object.instance_eval { remove_const klass } if Object.const_defined?(klass) 27 | Object.const_set(klass, Class.new).send(:include, model_type) 28 | Object.const_get(klass).class_eval(&block) if block_given? 29 | 30 | @spawned_models << klass.to_sym 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/her/model/associations/association_proxy.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Model 3 | module Associations 4 | class AssociationProxy < (ActiveSupport.const_defined?('ProxyObject') ? ActiveSupport::ProxyObject : ActiveSupport::BasicObject) 5 | 6 | # @private 7 | def self.install_proxy_methods(target_name, *names) 8 | names.each do |name| 9 | module_eval <<-RUBY, __FILE__, __LINE__ + 1 10 | def #{name}(*args, &block) 11 | #{target_name}.send(#{name.inspect}, *args, &block) 12 | end 13 | RUBY 14 | end 15 | end 16 | 17 | install_proxy_methods :association, 18 | :build, :create, :where, :find, :all, :assign_nested_attributes, :reload 19 | 20 | # @private 21 | def initialize(association) 22 | @_her_association = association 23 | end 24 | 25 | def association 26 | @_her_association 27 | end 28 | 29 | # @private 30 | def method_missing(name, *args, &block) 31 | if :object_id == name # avoid redefining object_id 32 | return association.fetch.object_id 33 | end 34 | 35 | # create a proxy to the fetched object's method 36 | AssociationProxy.install_proxy_methods 'association.fetch', name 37 | 38 | # resend message to fetched object 39 | __send__(name, *args, &block) 40 | end 41 | 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | _(This file is heavily based on [factory\_girl\_rails](https://github.com/thoughtbot/factory_girl_rails/blob/master/CONTRIBUTING.md)’s Contribution Guide)_ 4 | 5 | We love pull requests. Here’s a quick guide: 6 | 7 | * Fork the repository. 8 | * Run `rake spec` (to make sure you start with a clean slate). 9 | * Implement your feature or fix. 10 | * Add examples that describe it (in the `spec` directory). Only refactoring and documentation changes require no new tests. If you are adding functionality or fixing a bug, we need examples! 11 | * Make sure `rake spec` passes after your modifications. 12 | * Commit (bonus points for doing it in a `feature-*` branch). 13 | * Push to your fork and send your pull request! 14 | 15 | If we have not replied to your pull request in three or four days, do not hesitate to post another comment in it — yes, we can be lazy sometimes. 16 | 17 | ## Syntax Guide 18 | 19 | Do not hesitate to submit patches that fix syntax issues. Some may have slipped under our nose. 20 | 21 | * Two spaces, no tabs (but you already knew that, right?). 22 | * No trailing whitespace. Blank lines should not have any space. There are few things we **hate** more than trailing whitespace. Seriously. 23 | * `MyClass.my_method(my_arg)` not `my_method( my_arg )` or `my_method my_arg`. 24 | * `[:foo, :bar]` and not `[ :foo, :bar ]`, `{ :foo => :bar }` and not `{:foo => :bar}` 25 | * `a = b` and not `a=b`. 26 | * Follow the conventions you see used in the source already. 27 | -------------------------------------------------------------------------------- /lib/her/model/nested_attributes.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Model 3 | module NestedAttributes 4 | extend ActiveSupport::Concern 5 | 6 | module ClassMethods 7 | # Allow nested attributes for an association 8 | # 9 | # @example 10 | # class User 11 | # include Her::Model 12 | # 13 | # has_one :role 14 | # accepts_nested_attributes_for :role 15 | # end 16 | # 17 | # class Role 18 | # include Her::Model 19 | # end 20 | # 21 | # user = User.new(name: "Tobias", role_attributes: { title: "moderator" }) 22 | # user.role # => # 23 | def accepts_nested_attributes_for(*associations) 24 | allowed_association_names = association_names 25 | 26 | associations.each do |association_name| 27 | unless allowed_association_names.include?(association_name) 28 | raise Her::Errors::AssociationUnknownError.new("Unknown association name :#{association_name}") 29 | end 30 | 31 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 32 | if method_defined?(:#{association_name}_attributes=) 33 | remove_method(:#{association_name}_attributes=) 34 | end 35 | 36 | def #{association_name}_attributes=(attributes) 37 | self.#{association_name}.assign_nested_attributes(attributes) 38 | end 39 | RUBY 40 | end 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/her/json_api/model.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module JsonApi 3 | module Model 4 | 5 | def self.included(klass) 6 | klass.class_eval do 7 | include Her::Model 8 | 9 | [:parse_root_in_json, :include_root_in_json, :root_element, :primary_key].each do |method| 10 | define_method method do |*args| 11 | raise NoMethodError, "Her::JsonApi::Model does not support the #{method} configuration option" 12 | end 13 | end 14 | 15 | method_for :update, :patch 16 | 17 | @type = name.demodulize.tableize 18 | 19 | def self.parse(data) 20 | data.fetch(:attributes).merge(data.slice(:id)) 21 | end 22 | 23 | def self.to_params(attributes, changes={}) 24 | request_data = { type: @type }.tap { |request_body| 25 | attrs = attributes.dup.symbolize_keys.tap { |filtered_attributes| 26 | if her_api.options[:send_only_modified_attributes] 27 | filtered_attributes = changes.symbolize_keys.keys.inject({}) do |hash, attribute| 28 | hash[attribute] = filtered_attributes[attribute] 29 | hash 30 | end 31 | end 32 | } 33 | request_body[:id] = attrs.delete(:id) if attrs[:id] 34 | request_body[:attributes] = attrs 35 | } 36 | { data: request_data } 37 | end 38 | 39 | def self.type(type_name) 40 | @type = type_name.to_s 41 | end 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/model_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require "spec_helper" 3 | 4 | describe Her::Model do 5 | before do 6 | Her::API.setup url: "https://api.example.com" do |connection| 7 | connection.use Her::Middleware::FirstLevelParseJSON 8 | connection.adapter :test do |stub| 9 | stub.get("/users/1") { [200, {}, { id: 1, name: "Tobias Fünke" }.to_json] } 10 | stub.get("/users/1/comments") { [200, {}, [{ id: 4, body: "They're having a FIRESALE?" }].to_json] } 11 | end 12 | end 13 | 14 | spawn_model("Foo::User") { has_many :comments } 15 | spawn_model("Foo::Comment") 16 | end 17 | subject { Foo::User.find(1) } 18 | 19 | describe :has_key? do 20 | it { is_expected.not_to have_key(:unknown_method_for_a_user) } 21 | it { is_expected.not_to have_key(:unknown_method_for_a_user) } 22 | it { is_expected.to have_key(:name) } 23 | it { is_expected.to have_key(:comments) } 24 | end 25 | 26 | describe :serialization do 27 | it "should be serialized without an error" do 28 | expect { Marshal.dump(subject.comments) }.not_to raise_error 29 | end 30 | 31 | it "should correctly load serialized object" do 32 | serialized_comments = Marshal.load(Marshal.dump(subject.comments)) 33 | expect(subject.comments.size).to eq(serialized_comments.size) 34 | expect(subject.comments.first.id).to eq(serialized_comments.first.id) 35 | expect(subject.comments.first.body).to eq(serialized_comments.first.body) 36 | end 37 | end 38 | 39 | describe :[] do 40 | it { is_expected.not_to have_key(:unknown_method_for_a_user) } 41 | specify { expect(subject[:name]).to eq("Tobias Fünke") } 42 | specify { expect(subject[:comments].first.body).to eq("They're having a FIRESALE?") } 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/her/model/deprecated_methods.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Model 3 | # @private 4 | module DeprecatedMethods 5 | extend ActiveSupport::Concern 6 | 7 | def self.deprecate!(old, new, object, *args) 8 | line = begin 9 | raise StandardError 10 | rescue StandardError => e 11 | e.backtrace[2] 12 | end 13 | 14 | warn "#{line} - The `#{old}` method is deprecated and may be removed soon. Please update your code with `#{new}` instead." 15 | object.send(new, *args) 16 | end 17 | 18 | def data(*args) 19 | Her::Model::DeprecatedMethods.deprecate! :data, :attributes, self, *args 20 | end 21 | 22 | def data=(*args) 23 | Her::Model::DeprecatedMethods.deprecate! :data=, :attributes=, self, *args 24 | end 25 | 26 | def update_attributes(*args) 27 | Her::Model::DeprecatedMethods.deprecate! :update_attributes, :assign_attributes, self, *args 28 | end 29 | 30 | def assign_data(*args) 31 | Her::Model::DeprecatedMethods.deprecate! :assign_data, :assign_attributes, self, *args 32 | end 33 | 34 | def has_data?(*args) 35 | Her::Model::DeprecatedMethods.deprecate! :has_data?, :has_attribute?, self, *args 36 | end 37 | 38 | def get_data(*args) 39 | Her::Model::DeprecatedMethods.deprecate! :get_data, :get_attribute, self, *args 40 | end 41 | 42 | module ClassMethods 43 | def has_relationship?(*args) 44 | Her::Model::DeprecatedMethods.deprecate! :has_relationship?, :has_association?, self, *args 45 | end 46 | 47 | def get_relationship(*args) 48 | Her::Model::DeprecatedMethods.deprecate! :get_relationship, :get_association, self, *args 49 | end 50 | 51 | def relationships(*args) 52 | Her::Model::DeprecatedMethods.deprecate! :relationships, :associations, self, *args 53 | end 54 | 55 | def her_api(*args) 56 | Her::Model::DeprecatedMethods.deprecate! :her_api, :use_api, self, *args 57 | end 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/her/model/introspection.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Model 3 | module Introspection 4 | extend ActiveSupport::Concern 5 | # Inspect an element, returns it for introspection. 6 | # 7 | # @example 8 | # class User 9 | # include Her::Model 10 | # end 11 | # 12 | # @user = User.find(1) 13 | # p @user # => # 14 | def inspect 15 | resource_path = begin 16 | request_path 17 | rescue Her::Errors::PathError => e 18 | "" 19 | end 20 | 21 | "#<#{self.class}(#{resource_path}) #{attributes.keys.map { |k| "#{k}=#{attribute_for_inspect(send(k))}" }.join(" ")}>" 22 | end 23 | 24 | private 25 | def attribute_for_inspect(value) 26 | if value.is_a?(String) && value.length > 50 27 | "#{value[0..50]}...".inspect 28 | elsif value.is_a?(Date) || value.is_a?(Time) 29 | %("#{value}") 30 | else 31 | value.inspect 32 | end 33 | end 34 | 35 | # @private 36 | module ClassMethods 37 | # Finds a class at the same level as this one or at the global level. 38 | # 39 | # @private 40 | def her_nearby_class(name) 41 | her_sibling_class(name) || name.constantize rescue nil 42 | end 43 | 44 | protected 45 | # Looks for a class at the same level as this one with the given name. 46 | # 47 | # @private 48 | def her_sibling_class(name) 49 | if mod = self.her_containing_module 50 | @_her_sibling_class ||= Hash.new { Hash.new } 51 | @_her_sibling_class[mod][name] ||= "#{mod.name}::#{name}".constantize rescue nil 52 | end 53 | end 54 | 55 | # If available, returns the containing Module for this class. 56 | # 57 | # @private 58 | def her_containing_module 59 | return unless self.name =~ /::/ 60 | self.name.split("::")[0..-2].join("::").constantize 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/her/model.rb: -------------------------------------------------------------------------------- 1 | require "her/model/base" 2 | require "her/model/deprecated_methods" 3 | require "her/model/http" 4 | require "her/model/attributes" 5 | require "her/model/relation" 6 | require "her/model/orm" 7 | require "her/model/parse" 8 | require "her/model/associations" 9 | require "her/model/introspection" 10 | require "her/model/paths" 11 | require "her/model/nested_attributes" 12 | require "active_model" 13 | 14 | module Her 15 | # This module is the main element of Her. After creating a Her::API object, 16 | # include this module in your models to get a few magic methods defined in them. 17 | # 18 | # @example 19 | # class User 20 | # include Her::Model 21 | # end 22 | # 23 | # @user = User.new(:name => "Rémi") 24 | # @user.save 25 | module Model 26 | extend ActiveSupport::Concern 27 | 28 | # Her modules 29 | include Her::Model::Base 30 | include Her::Model::DeprecatedMethods 31 | include Her::Model::Attributes 32 | include Her::Model::ORM 33 | include Her::Model::HTTP 34 | include Her::Model::Parse 35 | include Her::Model::Introspection 36 | include Her::Model::Paths 37 | include Her::Model::Associations 38 | include Her::Model::NestedAttributes 39 | 40 | # Supported ActiveModel modules 41 | include ActiveModel::AttributeMethods 42 | include ActiveModel::Validations 43 | include ActiveModel::Validations::Callbacks 44 | include ActiveModel::Conversion 45 | include ActiveModel::Dirty 46 | 47 | # Class methods 48 | included do 49 | # Assign the default API 50 | use_api Her::API.default_api 51 | method_for :create, :post 52 | method_for :update, :put 53 | method_for :find, :get 54 | method_for :destroy, :delete 55 | method_for :new, :get 56 | 57 | # Define the default primary key 58 | primary_key :id 59 | 60 | # Define default storage accessors for errors and metadata 61 | store_response_errors :response_errors 62 | store_metadata :metadata 63 | 64 | # Include ActiveModel naming methods 65 | extend ActiveModel::Translation 66 | 67 | # Configure ActiveModel callbacks 68 | extend ActiveModel::Callbacks 69 | define_model_callbacks :create, :update, :save, :find, :destroy, :initialize 70 | 71 | # Define matchers for attr? and attr= methods 72 | define_attribute_method_matchers 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /spec/middleware/first_level_parse_json_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require "spec_helper" 3 | 4 | describe Her::Middleware::FirstLevelParseJSON do 5 | subject { described_class.new } 6 | let(:body_without_errors) { "{\"id\": 1, \"name\": \"Tobias Fünke\", \"metadata\": 3}" } 7 | let(:body_with_errors) { "{\"id\": 1, \"name\": \"Tobias Fünke\", \"errors\": { \"name\": [ \"not_valid\", \"should_be_present\" ] }, \"metadata\": 3}" } 8 | let(:body_with_malformed_json) { "wut." } 9 | let(:body_with_invalid_json) { "true" } 10 | let(:empty_body) { "" } 11 | let(:nil_body) { nil } 12 | 13 | it "parses body as json" do 14 | subject.parse(body_without_errors).tap do |json| 15 | expect(json[:data]).to eq(id: 1, name: "Tobias Fünke") 16 | expect(json[:metadata]).to eq(3) 17 | end 18 | end 19 | 20 | it "parses :body key as json in the env hash" do 21 | env = { body: body_without_errors } 22 | subject.on_complete(env) 23 | env[:body].tap do |json| 24 | expect(json[:data]).to eq(id: 1, name: "Tobias Fünke") 25 | expect(json[:metadata]).to eq(3) 26 | end 27 | end 28 | 29 | it "ensures the errors are a hash if there are no errors" do 30 | expect(subject.parse(body_without_errors)[:errors]).to eq({}) 31 | end 32 | 33 | it "ensures the errors are a hash if there are no errors" do 34 | expect(subject.parse(body_with_errors)[:errors]).to eq(name: %w(not_valid should_be_present)) 35 | end 36 | 37 | it "ensures that malformed JSON throws an exception" do 38 | expect { subject.parse(body_with_malformed_json) }.to raise_error(Her::Errors::ParseError, 'Response from the API must behave like a Hash or an Array (last JSON response was "wut.")') 39 | end 40 | 41 | it "ensures that invalid JSON throws an exception" do 42 | expect { subject.parse(body_with_invalid_json) }.to raise_error(Her::Errors::ParseError, 'Response from the API must behave like a Hash or an Array (last JSON response was "true")') 43 | end 44 | 45 | it "ensures that a nil response returns an empty hash" do 46 | expect(subject.parse(nil_body)[:data]).to eq({}) 47 | end 48 | 49 | it "ensures that an empty response returns an empty hash" do 50 | expect(subject.parse(empty_body)[:data]).to eq({}) 51 | end 52 | 53 | context "with status code 204" do 54 | it "returns an empty body" do 55 | env = { status: 204 } 56 | subject.on_complete(env) 57 | env[:body].tap do |json| 58 | expect(json[:data]).to eq({}) 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/her/model/associations/has_one_association.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Model 3 | module Associations 4 | class HasOneAssociation < Association 5 | 6 | # @private 7 | def self.attach(klass, name, opts) 8 | opts = { 9 | :class_name => name.to_s.classify, 10 | :name => name, 11 | :data_key => name, 12 | :default => nil, 13 | :path => "/#{name}" 14 | }.merge(opts) 15 | klass.associations[:has_one] << opts 16 | 17 | klass.class_eval <<-RUBY, __FILE__, __LINE__ + 1 18 | def #{name} 19 | cached_name = :"@_her_association_#{name}" 20 | 21 | cached_data = (instance_variable_defined?(cached_name) && instance_variable_get(cached_name)) 22 | cached_data || instance_variable_set(cached_name, Her::Model::Associations::HasOneAssociation.proxy(self, #{opts.inspect})) 23 | end 24 | RUBY 25 | end 26 | 27 | # @private 28 | def self.parse(*args) 29 | parse_single(*args) 30 | end 31 | 32 | # Initialize a new object with a foreign key to the parent 33 | # 34 | # @example 35 | # class User 36 | # include Her::Model 37 | # has_one :role 38 | # end 39 | # 40 | # class Role 41 | # include Her::Model 42 | # end 43 | # 44 | # user = User.find(1) 45 | # new_role = user.role.build(:title => "moderator") 46 | # new_role # => # 47 | def build(attributes = {}) 48 | @klass.build(attributes.merge(:"#{@parent.singularized_resource_name}_id" => @parent.id)) 49 | end 50 | 51 | # Create a new object, save it and associate it to the parent 52 | # 53 | # @example 54 | # class User 55 | # include Her::Model 56 | # has_one :role 57 | # end 58 | # 59 | # class Role 60 | # include Her::Model 61 | # end 62 | # 63 | # user = User.find(1) 64 | # user.role.create(:title => "moderator") 65 | # user.role # => # 66 | def create(attributes = {}) 67 | resource = build(attributes) 68 | @parent.attributes[@name] = resource if resource.save 69 | resource 70 | end 71 | 72 | # @private 73 | def assign_nested_attributes(attributes) 74 | assign_single_nested_attributes(attributes) 75 | end 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/model/introspection_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.join(File.dirname(__FILE__), "../spec_helper.rb") 3 | 4 | describe Her::Model::Introspection do 5 | context "introspecting a resource" do 6 | before do 7 | Her::API.setup url: "https://api.example.com" do |builder| 8 | builder.use Her::Middleware::FirstLevelParseJSON 9 | builder.use Faraday::Request::UrlEncoded 10 | builder.adapter :test do |stub| 11 | stub.post("/users") { [200, {}, { id: 1, name: "Tobias Funke" }.to_json] } 12 | stub.get("/users/1") { [200, {}, { id: 1, name: "Tobias Funke" }.to_json] } 13 | stub.put("/users/1") { [200, {}, { id: 1, name: "Tobias Funke" }.to_json] } 14 | stub.delete("/users/1") { [200, {}, { id: 1, name: "Tobias Funke" }.to_json] } 15 | stub.get("/projects/1/comments") { [200, {}, [{ id: 1, body: "Hello!" }].to_json] } 16 | end 17 | end 18 | 19 | spawn_model "Foo::User" 20 | spawn_model "Foo::Comment" do 21 | collection_path "projects/:project_id/comments" 22 | end 23 | end 24 | 25 | describe "#inspect" do 26 | it "outputs resource attributes for an existing resource" do 27 | @user = Foo::User.find(1) 28 | expect(["#", "#"]).to include(@user.inspect) 29 | end 30 | 31 | it "outputs resource attributes for an not-saved-yet resource" do 32 | @user = Foo::User.new(name: "Tobias Funke") 33 | expect(@user.inspect).to eq("#") 34 | end 35 | 36 | it "outputs resource attributes using getters" do 37 | @user = Foo::User.new(name: "Tobias Funke", password: "Funke") 38 | @user.instance_eval do 39 | def password 40 | "filtered" 41 | end 42 | end 43 | expect(@user.inspect).to include("name=\"Tobias Funke\"") 44 | expect(@user.inspect).to include("password=\"filtered\"") 45 | expect(@user.inspect).not_to include("password=\"Funke\"") 46 | end 47 | 48 | it "support dash on attribute" do 49 | @user = Foo::User.new(:'life-span' => "3 years") 50 | expect(@user.inspect).to include("life-span=\"3 years\"") 51 | end 52 | end 53 | 54 | describe "#inspect with errors in resource path" do 55 | it "prints the resource path as “unknown”" do 56 | @comment = Foo::Comment.where(project_id: 1).first 57 | path = "" 58 | expect(["#", "#"]).to include(@comment.inspect) 59 | end 60 | end 61 | end 62 | 63 | describe "#her_nearby_class" do 64 | context "for a class inside of a module" do 65 | before do 66 | spawn_model "Foo::User" 67 | spawn_model "Foo::AccessRecord" 68 | spawn_model "AccessRecord" 69 | spawn_model "Log" 70 | end 71 | 72 | it "returns a sibling class, if found" do 73 | expect(Foo::User.her_nearby_class("AccessRecord")).to eq(Foo::AccessRecord) 74 | expect(AccessRecord.her_nearby_class("Log")).to eq(Log) 75 | expect(Foo::User.her_nearby_class("Log")).to eq(Log) 76 | expect(Foo::User.her_nearby_class("X")).to be_nil 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/model/dirty_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.join(File.dirname(__FILE__), "../spec_helper.rb") 3 | 4 | describe "Her::Model and ActiveModel::Dirty" do 5 | context "checking dirty attributes" do 6 | before do 7 | Her::API.setup url: "https://api.example.com" do |builder| 8 | builder.use Her::Middleware::FirstLevelParseJSON 9 | builder.use Faraday::Request::UrlEncoded 10 | builder.adapter :test do |stub| 11 | stub.get("/users/1") { [200, {}, { id: 1, fullname: "Lindsay Fünke" }.to_json] } 12 | stub.get("/users/2") { [200, {}, { id: 2, fullname: "Maeby Fünke" }.to_json] } 13 | stub.get("/users/3") { [200, {}, { user_id: 3, fullname: "Maeby Fünke" }.to_json] } 14 | stub.put("/users/1") { [200, {}, { id: 1, fullname: "Tobias Fünke" }.to_json] } 15 | stub.put("/users/2") { [400, {}, { errors: ["Email cannot be blank"] }.to_json] } 16 | stub.post("/users") { [200, {}, { id: 1, fullname: "Tobias Fünke" }.to_json] } 17 | end 18 | end 19 | 20 | spawn_model "Foo::User" do 21 | attributes :fullname, :email 22 | end 23 | spawn_model "Dynamic::User" do 24 | primary_key :user_id 25 | end 26 | end 27 | 28 | context "for existing resource" do 29 | let(:user) { Foo::User.find(1) } 30 | it "has no changes" do 31 | expect(user.changes).to be_empty 32 | expect(user).not_to be_changed 33 | end 34 | context "with successful save" do 35 | it "tracks dirty attributes" do 36 | user.fullname = "Tobias Fünke" 37 | expect(user.fullname_changed?).to be_truthy 38 | expect(user.email_changed?).to be_falsey 39 | expect(user).to be_changed 40 | user.save 41 | expect(user).not_to be_changed 42 | end 43 | 44 | it "tracks only changed dirty attributes" do 45 | user.fullname = user.fullname 46 | expect(user.fullname_changed?).to be_falsey 47 | end 48 | 49 | it "tracks previous changes" do 50 | user.fullname = "Tobias Fünke" 51 | user.save 52 | expect(user.previous_changes).to eq("fullname" => "Lindsay Fünke") 53 | end 54 | 55 | it "tracks dirty attribute for mass assign for dynamic created attributes" do 56 | user = Dynamic::User.find(3) 57 | user.assign_attributes(fullname: "New Fullname") 58 | expect(user.fullname_changed?).to be_truthy 59 | expect(user).to be_changed 60 | expect(user.changes.length).to eq(1) 61 | end 62 | end 63 | 64 | context "with erroneous save" do 65 | it "tracks dirty attributes" do 66 | user = Foo::User.find(2) 67 | user.fullname = "Tobias Fünke" 68 | expect(user.fullname_changed?).to be_truthy 69 | expect(user.email_changed?).to be_falsey 70 | expect(user).to be_changed 71 | user.save 72 | expect(user).to be_changed 73 | end 74 | end 75 | end 76 | 77 | context "for new resource" do 78 | let(:user) { Foo::User.new(fullname: "Lindsay Fünke") } 79 | it "has changes" do 80 | expect(user).to be_changed 81 | end 82 | it "tracks dirty attributes" do 83 | user.fullname = "Tobias Fünke" 84 | expect(user.fullname_changed?).to be_truthy 85 | expect(user).to be_changed 86 | user.save 87 | expect(user).not_to be_changed 88 | end 89 | end 90 | end 91 | end 92 | # 93 | -------------------------------------------------------------------------------- /lib/her/model/associations/belongs_to_association.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Model 3 | module Associations 4 | class BelongsToAssociation < Association 5 | 6 | # @private 7 | def self.attach(klass, name, opts) 8 | opts = { 9 | :class_name => name.to_s.classify, 10 | :name => name, 11 | :data_key => name, 12 | :default => nil, 13 | :foreign_key => "#{name}_id", 14 | :path => "/#{name.to_s.pluralize}/:id" 15 | }.merge(opts) 16 | klass.associations[:belongs_to] << opts 17 | 18 | klass.class_eval <<-RUBY, __FILE__, __LINE__ + 1 19 | def #{name} 20 | cached_name = :"@_her_association_#{name}" 21 | 22 | cached_data = (instance_variable_defined?(cached_name) && instance_variable_get(cached_name)) 23 | cached_data || instance_variable_set(cached_name, Her::Model::Associations::BelongsToAssociation.proxy(self, #{opts.inspect})) 24 | end 25 | RUBY 26 | end 27 | 28 | # @private 29 | def self.parse(*args) 30 | parse_single(*args) 31 | end 32 | 33 | # Initialize a new object 34 | # 35 | # @example 36 | # class User 37 | # include Her::Model 38 | # belongs_to :organization 39 | # end 40 | # 41 | # class Organization 42 | # include Her::Model 43 | # end 44 | # 45 | # user = User.find(1) 46 | # new_organization = user.organization.build(:name => "Foo Inc.") 47 | # new_organization # => # 48 | def build(attributes = {}) 49 | @klass.build(attributes) 50 | end 51 | 52 | # Create a new object, save it and associate it to the parent 53 | # 54 | # @example 55 | # class User 56 | # include Her::Model 57 | # belongs_to :organization 58 | # end 59 | # 60 | # class Organization 61 | # include Her::Model 62 | # end 63 | # 64 | # user = User.find(1) 65 | # user.organization.create(:name => "Foo Inc.") 66 | # user.organization # => # 67 | def create(attributes = {}) 68 | resource = build(attributes) 69 | @parent.attributes[@name] = resource if resource.save 70 | resource 71 | end 72 | 73 | # @private 74 | def fetch 75 | foreign_key_value = @parent.attributes[@opts[:foreign_key].to_sym] 76 | data_key_value = @parent.attributes[@opts[:data_key].to_sym] 77 | return @opts[:default].try(:dup) if (@parent.attributes.include?(@name) && @parent.attributes[@name].nil? && @params.empty?) || (foreign_key_value.blank? && data_key_value.blank?) 78 | 79 | return @cached_result unless @params.any? || @cached_result.nil? 80 | return @parent.attributes[@name] unless @params.any? || @parent.attributes[@name].blank? 81 | 82 | path_params = @parent.attributes.merge(@params.merge(@klass.primary_key => foreign_key_value)) 83 | path = build_association_path lambda { @klass.build_request_path(path_params) } 84 | @klass.get_resource(path, @params).tap do |result| 85 | @cached_result = result if @params.blank? 86 | end 87 | end 88 | 89 | # @private 90 | def assign_nested_attributes(attributes) 91 | assign_single_nested_attributes(attributes) 92 | end 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/her/model/associations/has_many_association.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Model 3 | module Associations 4 | class HasManyAssociation < Association 5 | 6 | # @private 7 | def self.attach(klass, name, opts) 8 | opts = { 9 | :class_name => name.to_s.classify, 10 | :name => name, 11 | :data_key => name, 12 | :default => Her::Collection.new, 13 | :path => "/#{name}", 14 | :inverse_of => nil 15 | }.merge(opts) 16 | klass.associations[:has_many] << opts 17 | 18 | klass.class_eval <<-RUBY, __FILE__, __LINE__ + 1 19 | def #{name} 20 | cached_name = :"@_her_association_#{name}" 21 | 22 | cached_data = (instance_variable_defined?(cached_name) && instance_variable_get(cached_name)) 23 | cached_data || instance_variable_set(cached_name, Her::Model::Associations::HasManyAssociation.proxy(self, #{opts.inspect})) 24 | end 25 | RUBY 26 | end 27 | 28 | # @private 29 | def self.parse(association, klass, data) 30 | data_key = association[:data_key] 31 | return {} unless data[data_key] 32 | 33 | klass = klass.her_nearby_class(association[:class_name]) 34 | { association[:name] => Her::Model::Attributes.initialize_collection(klass, :data => data[data_key]) } 35 | end 36 | 37 | # Initialize a new object with a foreign key to the parent 38 | # 39 | # @example 40 | # class User 41 | # include Her::Model 42 | # has_many :comments 43 | # end 44 | # 45 | # class Comment 46 | # include Her::Model 47 | # end 48 | # 49 | # user = User.find(1) 50 | # new_comment = user.comments.build(:body => "Hello!") 51 | # new_comment # => # 52 | # TODO: This only merges the id of the parents, handle the case 53 | # where this is more deeply nested 54 | def build(attributes = {}) 55 | @klass.build(attributes.merge(:"#{@parent.singularized_resource_name}_id" => @parent.id)) 56 | end 57 | 58 | # Create a new object, save it and add it to the associated collection 59 | # 60 | # @example 61 | # class User 62 | # include Her::Model 63 | # has_many :comments 64 | # end 65 | # 66 | # class Comment 67 | # include Her::Model 68 | # end 69 | # 70 | # user = User.find(1) 71 | # user.comments.create(:body => "Hello!") 72 | # user.comments # => [#] 73 | def create(attributes = {}) 74 | resource = build(attributes) 75 | 76 | if resource.save 77 | @parent.attributes[@name] ||= Her::Collection.new 78 | @parent.attributes[@name] << resource 79 | end 80 | 81 | resource 82 | end 83 | 84 | # @private 85 | def fetch 86 | super.tap do |o| 87 | inverse_of = @opts[:inverse_of] || @parent.singularized_resource_name 88 | o.each { |entry| entry.send("#{inverse_of}=", @parent) } 89 | end 90 | end 91 | 92 | # @private 93 | def assign_nested_attributes(attributes) 94 | data = attributes.is_a?(Hash) ? attributes.values : attributes 95 | @parent.attributes[@name] = Her::Model::Attributes.initialize_collection(@klass, :data => data) 96 | end 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Upgrade Her 2 | 3 | Here is a list of notable changes by release. Her follows the [Semantic Versioning](http://semver.org/) system. 4 | 5 | ## 0.8.1 (Note: 0.8.0 yanked) 6 | 7 | - Initial support for JSONAPI [link](https://github.com/remiprev/her/pull/347) 8 | - Fix for has_one association parsing [link](https://github.com/remiprev/her/pull/352) 9 | - Fix for escaping path variables HT @marshall-lee [link](https://github.com/remiprev/her/pull/354) 10 | - Fix syntax highlighting in README HT @tippenein [link](https://github.com/remiprev/her/pull/356) 11 | - Fix associations with Active Model Serializers HT @minktom [link](https://github.com/remiprev/her/pull/359) 12 | 13 | ## 0.7.6 14 | 15 | - Loosen restrictions on ActiveSupport and ActiveModel to accommodate security fixes [link](https://github.com/remiprev/her/commit/8ff641fcdaf14be7cc9b1a6ee6654f27f7dfa34c) 16 | 17 | ## 0.7.5 18 | 19 | - Performance fix for responses with large number of objects [link](https://github.com/remiprev/her/pull/337) 20 | - Bugfix for dirty attributes [link](https://github.com/remiprev/her/commit/70285debc6837a33a3a750c7c4a7251439464b42) 21 | - Add ruby 2.1 and 2.2 to travis test run. We will likely be removing official 1.9.x support in the near future, and 22 | will begin to align our support with the official ruby maintenance schedule. 23 | - README updates 24 | 25 | ## 0.6 26 | 27 | Associations have been refactored so that calling the association name method doesn’t immediately load or fetch the data. 28 | 29 | ```ruby 30 | class User 31 | include Her::Model 32 | has_many :comments 33 | end 34 | 35 | # This doesn’t fetch the data yet and it’s still chainable 36 | comments = User.find(1).comments 37 | 38 | # This actually fetches the data 39 | puts comments.inspect 40 | 41 | # This is no longer possible in her-0.6 42 | comments = User.find(1).comments(:approved => 1) 43 | 44 | # To pass additional parameters to the HTTP request, we now have to do this 45 | comments = User.find(1).comments.where(:approved => 1) 46 | ``` 47 | 48 | ## 0.5 49 | 50 | Her is now compatible with `ActiveModel` and includes `ActiveModel::Validations`. 51 | 52 | Before 0.5, the `errors` method on an object would return an error list received from the server (the `:errors` key defined by the parsing middleware). But now, `errors` returns the error list generated after calling the `valid?` method (or any other similar validation method from `ActiveModel::Validations`). The error list returned from the server is now accessible from the `response_errors` method. 53 | 54 | Since 0.5.5, Her provides a `store_response_errors` method, which allows you to choose the method which will return the response errors. You can use it to revert Her back to its original behavior (ie. `errors` returning the response errors): 55 | 56 | ```ruby 57 | class User 58 | include Her::Model 59 | store_response_errors :errors 60 | end 61 | 62 | user = User.create(:email => "foo") # POST /users returns { :errors => ["Email is invalid"] } 63 | user.errors # => ["Email is invalid"] 64 | ``` 65 | 66 | ## 0.2.4 67 | 68 | Her no longer includes default middleware when making HTTP requests. The user has now to define all the needed middleware. Before: 69 | 70 | ```ruby 71 | Her::API.setup :url => "https://api.example.com" do |connection| 72 | connection.insert(0, FaradayMiddle::OAuth) 73 | end 74 | ``` 75 | 76 | Now: 77 | 78 | ```ruby 79 | Her::API.setup :url => "https://api.example.com" do |connection| 80 | connection.use FaradayMiddle::OAuth 81 | connection.use Her::Middleware::FirstLevelParseJSON 82 | connection.use Faraday::Request::UrlEncoded 83 | connection.use Faraday::Adapter::NetHttp 84 | end 85 | ``` 86 | 87 | ## 0.2 88 | 89 | The default parser middleware has been replaced to treat first-level JSON data as the resource or collection data. Before it expected this: 90 | 91 | ```json 92 | { "data": { "id": 1, "name": "Foo" }, "errors": [] } 93 | ``` 94 | 95 | Now it expects this (the `errors` key is not treated as resource data): 96 | 97 | ```json 98 | { "id": 1, "name": "Foo", "errors": [] } 99 | ``` 100 | 101 | If you still want to get the old behavior, you can use `Her::Middleware::SecondLevelParseJSON` instead of `Her::Middleware::FirstLevelParseJSON` in your middleware stack. 102 | -------------------------------------------------------------------------------- /spec/json_api/model_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Her::JsonApi::Model do 4 | before do 5 | Her::API.setup url: "https://api.example.com" do |connection| 6 | connection.use Her::Middleware::JsonApiParser 7 | connection.adapter :test do |stub| 8 | stub.get("/users/1") do 9 | [ 10 | 200, 11 | {}, 12 | { 13 | data: { 14 | id: 1, 15 | type: "users", 16 | attributes: { 17 | name: "Roger Federer" 18 | } 19 | } 20 | 21 | }.to_json 22 | ] 23 | end 24 | 25 | stub.get("/users") do 26 | [ 27 | 200, 28 | {}, 29 | { 30 | data: [ 31 | { 32 | id: 1, 33 | type: "users", 34 | attributes: { 35 | name: "Roger Federer" 36 | } 37 | }, 38 | { 39 | id: 2, 40 | type: "users", 41 | attributes: { 42 | name: "Kei Nishikori" 43 | } 44 | } 45 | ] 46 | }.to_json 47 | ] 48 | end 49 | 50 | stub.post("/users", data: 51 | { 52 | type: "users", 53 | attributes: { 54 | name: "Jeremy Lin" 55 | } 56 | }) do 57 | [ 58 | 201, 59 | {}, 60 | { 61 | data: { 62 | id: 3, 63 | type: "users", 64 | attributes: { 65 | name: "Jeremy Lin" 66 | } 67 | } 68 | 69 | }.to_json 70 | ] 71 | end 72 | 73 | stub.patch("/users/1", data: 74 | { 75 | type: "users", 76 | id: 1, 77 | attributes: { 78 | name: "Fed GOAT" 79 | } 80 | }) do 81 | [ 82 | 200, 83 | {}, 84 | { 85 | data: { 86 | id: 1, 87 | type: "users", 88 | attributes: { 89 | name: "Fed GOAT" 90 | } 91 | } 92 | 93 | }.to_json 94 | ] 95 | end 96 | 97 | stub.delete("/users/1") do 98 | [204, {}, {}] 99 | end 100 | end 101 | end 102 | 103 | spawn_model("Foo::User", type: Her::JsonApi::Model) 104 | end 105 | 106 | it "allows configuration of type" do 107 | spawn_model("Foo::Bar", type: Her::JsonApi::Model) do 108 | type :foobars 109 | end 110 | 111 | expect(Foo::Bar.instance_variable_get("@type")).to eql("foobars") 112 | end 113 | 114 | it "finds models by id" do 115 | user = Foo::User.find(1) 116 | expect(user.attributes).to eql( 117 | "id" => 1, 118 | "name" => "Roger Federer" 119 | ) 120 | end 121 | 122 | it "finds a collection of models" do 123 | users = Foo::User.all 124 | expect(users.map(&:attributes)).to match_array( 125 | [ 126 | { 127 | "id" => 1, 128 | "name" => "Roger Federer" 129 | }, 130 | { 131 | "id" => 2, 132 | "name" => "Kei Nishikori" 133 | } 134 | ] 135 | ) 136 | end 137 | 138 | it "creates a Foo::User" do 139 | user = Foo::User.new(name: "Jeremy Lin") 140 | user.save 141 | expect(user.attributes).to eql( 142 | "id" => 3, 143 | "name" => "Jeremy Lin" 144 | ) 145 | end 146 | 147 | it "updates a Foo::User" do 148 | user = Foo::User.find(1) 149 | user.name = "Fed GOAT" 150 | user.save 151 | expect(user.attributes).to eql( 152 | "id" => 1, 153 | "name" => "Fed GOAT" 154 | ) 155 | end 156 | 157 | it "destroys a Foo::User" do 158 | user = Foo::User.find(1) 159 | expect(user.destroy).to be_destroyed 160 | end 161 | 162 | context "undefined methods" do 163 | it "removes methods that are not compatible with json api" do 164 | [:parse_root_in_json, :include_root_in_json, :root_element, :primary_key].each do |method| 165 | expect { Foo::User.new.send(method, :foo) }.to raise_error NoMethodError, "Her::JsonApi::Model does not support the #{method} configuration option" 166 | end 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /lib/her/model/paths.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Model 3 | module Paths 4 | extend ActiveSupport::Concern 5 | # Return a path based on the collection path and a resource data 6 | # 7 | # @example 8 | # class User 9 | # include Her::Model 10 | # collection_path "/utilisateurs" 11 | # end 12 | # 13 | # User.find(1) # Fetched via GET /utilisateurs/1 14 | # 15 | # @param [Hash] params An optional set of additional parameters for 16 | # path construction. These will not override attributes of the resource. 17 | def request_path(params = {}) 18 | self.class.build_request_path(params.merge(attributes.dup)) 19 | end 20 | 21 | module ClassMethods 22 | 23 | # Define the primary key field that will be used to find and save records 24 | # 25 | # @example 26 | # class User 27 | # include Her::Model 28 | # primary_key 'UserId' 29 | # end 30 | # 31 | # @param [Symbol] value 32 | def primary_key(value = nil) 33 | @_her_primary_key ||= begin 34 | superclass.primary_key if superclass.respond_to?(:primary_key) 35 | end 36 | 37 | return @_her_primary_key unless value 38 | @_her_primary_key = value.to_sym 39 | end 40 | 41 | # Defines a custom collection path for the resource 42 | # 43 | # @example 44 | # class User 45 | # include Her::Model 46 | # collection_path "/users" 47 | # end 48 | def collection_path(path = nil) 49 | if path.nil? 50 | @_her_collection_path ||= root_element.to_s.pluralize 51 | else 52 | @_her_collection_path = path 53 | @_her_resource_path = "#{path}/:id" 54 | end 55 | end 56 | 57 | # Defines a custom resource path for the resource 58 | # 59 | # @example 60 | # class User 61 | # include Her::Model 62 | # resource_path "/users/:id" 63 | # end 64 | # 65 | # Note that, if used in combination with resource_path, you may specify 66 | # either the real primary key or the string ':id'. For example: 67 | # 68 | # @example 69 | # class User 70 | # include Her::Model 71 | # primary_key 'user_id' 72 | # 73 | # # This works because we'll have a user_id attribute 74 | # resource_path '/users/:user_id' 75 | # 76 | # # This works because we replace :id with :user_id 77 | # resource_path '/users/:id' 78 | # end 79 | # 80 | def resource_path(path = nil) 81 | if path.nil? 82 | @_her_resource_path ||= "#{root_element.to_s.pluralize}/:id" 83 | else 84 | @_her_resource_path = path 85 | end 86 | end 87 | 88 | # Return a custom path based on the collection path and variable parameters 89 | # 90 | # @private 91 | def build_request_path(path=nil, parameters={}) 92 | parameters = parameters.try(:with_indifferent_access) 93 | 94 | unless path.is_a?(String) 95 | parameters = path.try(:with_indifferent_access) || parameters 96 | path = 97 | if parameters.include?(primary_key) && parameters[primary_key] && !parameters[primary_key].kind_of?(Array) 98 | resource_path.dup 99 | else 100 | collection_path.dup 101 | end 102 | 103 | # Replace :id with our actual primary key 104 | path.gsub!(/(\A|\/):id(\Z|\/)/, "\\1:#{primary_key}\\2") 105 | end 106 | 107 | path.gsub(/:([\w_]+)/) do 108 | # Look for :key or :_key, otherwise raise an exception 109 | key = $1.to_sym 110 | value = parameters.delete(key) || parameters.delete(:"_#{key}") 111 | if value 112 | Faraday::Utils.escape value 113 | else 114 | raise(Her::Errors::PathError.new("Missing :_#{$1} parameter to build the request path. Path is `#{path}`. Parameters are `#{parameters.symbolize_keys.inspect}`.", $1)) 115 | end 116 | end 117 | end 118 | 119 | # @private 120 | def build_request_path_from_string_or_symbol(path, params={}) 121 | path.is_a?(Symbol) ? "#{build_request_path(params)}/#{path}" : path 122 | end 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/her/model/http.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Model 3 | # This module interacts with Her::API to fetch HTTP data 4 | module HTTP 5 | extend ActiveSupport::Concern 6 | METHODS = [:get, :post, :put, :patch, :delete] 7 | 8 | # For each HTTP method, define these class methods: 9 | # 10 | # - (path, params) 11 | # - _raw(path, params, &block) 12 | # - _collection(path, params, &block) 13 | # - _resource(path, params, &block) 14 | # - custom_(*paths) 15 | # 16 | # @example 17 | # class User 18 | # include Her::Model 19 | # custom_get :active 20 | # end 21 | # 22 | # User.get(:popular) # GET "/users/popular" 23 | # User.active # GET "/users/active" 24 | module ClassMethods 25 | # Change which API the model will use to make its HTTP requests 26 | # 27 | # @example 28 | # secondary_api = Her::API.new :url => "https://api.example" do |connection| 29 | # connection.use Faraday::Request::UrlEncoded 30 | # connection.use Her::Middleware::DefaultParseJSON 31 | # end 32 | # 33 | # class User 34 | # include Her::Model 35 | # use_api secondary_api 36 | # end 37 | def use_api(value = nil) 38 | @_her_use_api ||= begin 39 | superclass.use_api if superclass.respond_to?(:use_api) 40 | end 41 | 42 | unless value 43 | return (@_her_use_api.respond_to? :call) ? @_her_use_api.call : @_her_use_api 44 | end 45 | 46 | @_her_use_api = value 47 | end 48 | 49 | alias her_api use_api 50 | alias uses_api use_api 51 | 52 | # Main request wrapper around Her::API. Used to make custom request to the API. 53 | # 54 | # @private 55 | def request(params={}) 56 | request = her_api.request(params) 57 | 58 | if block_given? 59 | yield request[:parsed_data], request[:response] 60 | else 61 | { :parsed_data => request[:parsed_data], :response => request[:response] } 62 | end 63 | end 64 | 65 | METHODS.each do |method| 66 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 67 | def #{method}(path, params={}) 68 | path = build_request_path_from_string_or_symbol(path, params) 69 | params = to_params(params) unless #{method.to_sym.inspect} == :get 70 | send(:'#{method}_raw', path, params) do |parsed_data, response| 71 | if parsed_data[:data].is_a?(Array) || active_model_serializers_format? || json_api_format? 72 | new_collection(parsed_data) 73 | else 74 | new(parse(parsed_data[:data]).merge :_metadata => parsed_data[:metadata], :_errors => parsed_data[:errors]) 75 | end 76 | end 77 | end 78 | 79 | def #{method}_raw(path, params={}, &block) 80 | path = build_request_path_from_string_or_symbol(path, params) 81 | request(params.merge(:_method => #{method.to_sym.inspect}, :_path => path), &block) 82 | end 83 | 84 | def #{method}_collection(path, params={}) 85 | path = build_request_path_from_string_or_symbol(path, params) 86 | send(:'#{method}_raw', build_request_path_from_string_or_symbol(path, params), params) do |parsed_data, response| 87 | new_collection(parsed_data) 88 | end 89 | end 90 | 91 | def #{method}_resource(path, params={}) 92 | path = build_request_path_from_string_or_symbol(path, params) 93 | send(:"#{method}_raw", path, params) do |parsed_data, response| 94 | new(parse(parsed_data[:data]).merge :_metadata => parsed_data[:metadata], :_errors => parsed_data[:errors]) 95 | end 96 | end 97 | 98 | def custom_#{method}(*paths) 99 | metaclass = (class << self; self; end) 100 | opts = paths.last.is_a?(Hash) ? paths.pop : Hash.new 101 | 102 | paths.each do |path| 103 | metaclass.send(:define_method, path) do |*params| 104 | params = params.first || Hash.new 105 | send(#{method.to_sym.inspect}, path, params) 106 | end 107 | end 108 | end 109 | RUBY 110 | end 111 | end 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/api_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.join(File.dirname(__FILE__), "spec_helper.rb") 3 | 4 | describe Her::API do 5 | subject { Her::API.new } 6 | 7 | context "initialization" do 8 | describe "#setup" do 9 | context "when setting custom middleware" do 10 | before do 11 | class Foo; end 12 | class Bar; end 13 | 14 | subject.setup url: "https://api.example.com" do |connection| 15 | connection.use Foo 16 | connection.use Bar 17 | end 18 | end 19 | 20 | specify { expect(subject.connection.builder.handlers).to eq([Foo, Bar]) } 21 | end 22 | 23 | context "when setting custom options" do 24 | before { subject.setup foo: { bar: "baz" }, url: "https://api.example.com" } 25 | 26 | describe "#options" do 27 | it { expect(subject.options).to eq(foo: { bar: "baz" }, url: "https://api.example.com") } 28 | end 29 | end 30 | end 31 | 32 | describe "#request" do 33 | before do 34 | class SimpleParser < Faraday::Response::Middleware 35 | def on_complete(env) 36 | env[:body] = { data: env[:body] } 37 | end 38 | end 39 | end 40 | 41 | context "making HTTP requests" do 42 | let(:parsed_data) { subject.request(_method: :get, _path: "/foo")[:parsed_data] } 43 | before do 44 | subject.setup url: "https://api.example.com" do |builder| 45 | builder.use SimpleParser 46 | builder.adapter(:test) { |stub| stub.get("/foo") { [200, {}, "Foo, it is."] } } 47 | end 48 | end 49 | 50 | specify { expect(parsed_data[:data]).to eq("Foo, it is.") } 51 | end 52 | 53 | context "making HTTP requests while specifying custom HTTP headers" do 54 | let(:parsed_data) { subject.request(_method: :get, _path: "/foo", _headers: { "X-Page" => 2 })[:parsed_data] } 55 | 56 | before do 57 | subject.setup url: "https://api.example.com" do |builder| 58 | builder.use SimpleParser 59 | builder.adapter(:test) { |stub| stub.get("/foo") { |env| [200, {}, "Foo, it is page #{env[:request_headers]['X-Page']}."] } } 60 | end 61 | end 62 | 63 | specify { expect(parsed_data[:data]).to eq("Foo, it is page 2.") } 64 | end 65 | 66 | context "parsing a request with the default parser" do 67 | let(:parsed_data) { subject.request(_method: :get, _path: "users/1")[:parsed_data] } 68 | before do 69 | subject.setup url: "https://api.example.com" do |builder| 70 | builder.use Her::Middleware::FirstLevelParseJSON 71 | builder.adapter :test do |stub| 72 | stub.get("/users/1") { [200, {}, MultiJson.dump(id: 1, name: "George Michael Bluth", errors: ["This is a single error"], metadata: { page: 1, per_page: 10 })] } 73 | end 74 | end 75 | end 76 | 77 | specify do 78 | expect(parsed_data[:data]).to eq(id: 1, name: "George Michael Bluth") 79 | expect(parsed_data[:errors]).to eq(["This is a single error"]) 80 | expect(parsed_data[:metadata]).to eq(page: 1, per_page: 10) 81 | end 82 | end 83 | 84 | context "parsing a request with a custom parser" do 85 | let(:parsed_data) { subject.request(_method: :get, _path: "users/1")[:parsed_data] } 86 | before do 87 | class CustomParser < Faraday::Response::Middleware 88 | def on_complete(env) 89 | json = MultiJson.load(env[:body], symbolize_keys: true) 90 | errors = json.delete(:errors) || [] 91 | metadata = json.delete(:metadata) || {} 92 | env[:body] = { 93 | data: json, 94 | errors: errors, 95 | metadata: metadata 96 | } 97 | end 98 | end 99 | 100 | subject.setup url: "https://api.example.com" do |builder| 101 | builder.use CustomParser 102 | builder.use Faraday::Request::UrlEncoded 103 | builder.adapter :test do |stub| 104 | stub.get("/users/1") { [200, {}, MultiJson.dump(id: 1, name: "George Michael Bluth")] } 105 | end 106 | end 107 | end 108 | 109 | specify do 110 | expect(parsed_data[:data]).to eq(id: 1, name: "George Michael Bluth") 111 | expect(parsed_data[:errors]).to eq([]) 112 | expect(parsed_data[:metadata]).to eq({}) 113 | end 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/her/model/associations/association.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Model 3 | module Associations 4 | class Association 5 | # @private 6 | attr_accessor :params 7 | 8 | # @private 9 | def initialize(parent, opts = {}) 10 | @parent = parent 11 | @opts = opts 12 | @params = {} 13 | 14 | @klass = @parent.class.her_nearby_class(@opts[:class_name]) 15 | @name = @opts[:name] 16 | end 17 | 18 | # @private 19 | def self.proxy(parent, opts = {}) 20 | AssociationProxy.new new(parent, opts) 21 | end 22 | 23 | # @private 24 | def self.parse_single(association, klass, data) 25 | data_key = association[:data_key] 26 | return {} unless data[data_key] 27 | 28 | klass = klass.her_nearby_class(association[:class_name]) 29 | if data[data_key].kind_of?(klass) 30 | { association[:name] => data[data_key] } 31 | else 32 | { association[:name] => klass.new(klass.parse(data[data_key])) } 33 | end 34 | end 35 | 36 | # @private 37 | def assign_single_nested_attributes(attributes) 38 | if @parent.attributes[@name].blank? 39 | @parent.attributes[@name] = @klass.new(@klass.parse(attributes)) 40 | else 41 | @parent.attributes[@name].assign_attributes(attributes) 42 | end 43 | end 44 | 45 | # @private 46 | def fetch(opts = {}) 47 | attribute_value = @parent.attributes[@name] 48 | return @opts[:default].try(:dup) if @parent.attributes.include?(@name) && (attribute_value.nil? || !attribute_value.nil? && attribute_value.empty?) && @params.empty? 49 | 50 | return @cached_result unless @params.any? || @cached_result.nil? 51 | return @parent.attributes[@name] unless @params.any? || @parent.attributes[@name].blank? 52 | 53 | path = build_association_path lambda { "#{@parent.request_path(@params)}#{@opts[:path]}" } 54 | @klass.get(path, @params).tap do |result| 55 | @cached_result = result unless @params.any? 56 | end 57 | end 58 | 59 | # @private 60 | def build_association_path(code) 61 | begin 62 | instance_exec(&code) 63 | rescue Her::Errors::PathError 64 | return nil 65 | end 66 | end 67 | 68 | # @private 69 | def reset 70 | @params = {} 71 | @cached_result = nil 72 | @parent.attributes.delete(@name) 73 | end 74 | 75 | # Add query parameters to the HTTP request performed to fetch the data 76 | # 77 | # @example 78 | # class User 79 | # include Her::Model 80 | # has_many :comments 81 | # end 82 | # 83 | # user = User.find(1) 84 | # user.comments.where(:approved => 1) # Fetched via GET "/users/1/comments?approved=1 85 | def where(params = {}) 86 | return self if params.blank? && @parent.attributes[@name].blank? 87 | AssociationProxy.new self.clone.tap { |a| a.params = a.params.merge(params) } 88 | end 89 | alias all where 90 | 91 | # Fetches the data specified by id 92 | # 93 | # @example 94 | # class User 95 | # include Her::Model 96 | # has_many :comments 97 | # end 98 | # 99 | # user = User.find(1) 100 | # user.comments.find(3) # Fetched via GET "/users/1/comments/3 101 | def find(id) 102 | return nil if id.blank? 103 | path = build_association_path lambda { "#{@parent.request_path(@params)}#{@opts[:path]}/#{id}" } 104 | @klass.get_resource(path, @params) 105 | end 106 | 107 | # Refetches the association and puts the proxy back in its initial state, 108 | # which is unloaded. Cached associations are cleared. 109 | # 110 | # @example 111 | # class User 112 | # include Her::Model 113 | # has_many :comments 114 | # end 115 | # 116 | # class Comment 117 | # include Her::Model 118 | # end 119 | # 120 | # user = User.find(1) 121 | # user.comments = [#] 122 | # user.comments.first.id = "Oops" 123 | # user.comments.reload # => [#] 124 | # # Fetched again via GET "/users/1/comments" 125 | def reload 126 | reset 127 | fetch 128 | end 129 | 130 | end 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/her/api.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | # This class is where all HTTP requests are made. Before using Her, you must configure it 3 | # so it knows where to make those requests. In Rails, this is usually done in `config/initializers/her.rb`: 4 | class API 5 | # @private 6 | attr_reader :connection, :options 7 | 8 | # Constants 9 | FARADAY_OPTIONS = [:request, :proxy, :ssl, :builder, :url, :parallel_manager, :params, :headers, :builder_class, :timeout, :open_timeout].freeze 10 | 11 | # Setup a default API connection. Accepted arguments and options are the same as {API#setup}. 12 | def self.setup(opts={}, &block) 13 | @default_api = new(opts, &block) 14 | end 15 | 16 | # Create a new API object. This is useful to create multiple APIs and use them with the `uses_api` method. 17 | # If your application uses only one API, you should use Her::API.setup to configure the default API 18 | # 19 | # @example Setting up a new API 20 | # api = Her::API.new :url => "https://api.example" do |connection| 21 | # connection.use Faraday::Request::UrlEncoded 22 | # connection.use Her::Middleware::DefaultParseJSON 23 | # end 24 | # 25 | # class User 26 | # uses_api api 27 | # end 28 | def initialize(*args, &blk) 29 | setup(*args, &blk) 30 | end 31 | 32 | # Setup the API connection. 33 | # 34 | # @param [Hash] opts the Faraday options 35 | # @option opts [String] :url The main HTTP API root (eg. `https://api.example.com`) 36 | # @option opts [String] :ssl A hash containing [SSL options](https://github.com/lostisland/faraday/wiki/Setting-up-SSL-certificates) 37 | # 38 | # @return Faraday::Connection 39 | # 40 | # @example Setting up the default API connection 41 | # Her::API.setup :url => "https://api.example" 42 | # 43 | # @example A custom middleware added to the default list 44 | # class MyAuthentication < Faraday::Middleware 45 | # def call(env) 46 | # env[:request_headers]["X-API-Token"] = "bb2b2dd75413d32c1ac421d39e95b978d1819ff611f68fc2fdd5c8b9c7331192" 47 | # @app.call(env) 48 | # end 49 | # end 50 | # Her::API.setup :url => "https://api.example.com" do |connection| 51 | # connection.use Faraday::Request::UrlEncoded 52 | # connection.use Her::Middleware::DefaultParseJSON 53 | # connection.use MyAuthentication 54 | # connection.use Faraday::Adapter::NetHttp 55 | # end 56 | # 57 | # @example A custom parse middleware 58 | # class MyCustomParser < Faraday::Response::Middleware 59 | # def on_complete(env) 60 | # json = JSON.parse(env[:body], :symbolize_names => true) 61 | # errors = json.delete(:errors) || {} 62 | # metadata = json.delete(:metadata) || [] 63 | # env[:body] = { :data => json, :errors => errors, :metadata => metadata } 64 | # end 65 | # end 66 | # Her::API.setup :url => "https://api.example.com" do |connection| 67 | # connection.use Faraday::Request::UrlEncoded 68 | # connection.use MyCustomParser 69 | # connection.use Faraday::Adapter::NetHttp 70 | # end 71 | def setup(opts={}, &blk) 72 | opts[:url] = opts.delete(:base_uri) if opts.include?(:base_uri) # Support legacy :base_uri option 73 | @options = opts 74 | 75 | faraday_options = @options.reject { |key, value| !FARADAY_OPTIONS.include?(key.to_sym) } 76 | @connection = Faraday.new(faraday_options) do |connection| 77 | yield connection if block_given? 78 | end 79 | self 80 | end 81 | 82 | # Define a custom parsing procedure. The procedure is passed the response object and is 83 | # expected to return a hash with three keys: a main data Hash, an errors Hash 84 | # and a metadata Hash. 85 | # 86 | # @private 87 | def request(opts={}) 88 | method = opts.delete(:_method) 89 | path = opts.delete(:_path) 90 | headers = opts.delete(:_headers) 91 | opts.delete_if { |key, value| key.to_s =~ /^_/ } # Remove all internal parameters 92 | response = @connection.send method do |request| 93 | request.headers.merge!(headers) if headers 94 | if method == :get 95 | # For GET requests, treat additional parameters as querystring data 96 | request.url path, opts 97 | else 98 | # For POST, PUT and DELETE requests, treat additional parameters as request body 99 | request.url path 100 | request.body = opts 101 | end 102 | end 103 | 104 | { :parsed_data => response.env[:body], :response => response } 105 | end 106 | 107 | private 108 | # @private 109 | def self.default_api(opts={}) 110 | defined?(@default_api) ? @default_api : nil 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /spec/model/callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.join(File.dirname(__FILE__), "../spec_helper.rb") 3 | 4 | describe "Her::Model and ActiveModel::Callbacks" do 5 | before do 6 | Her::API.setup url: "https://api.example.com" do |builder| 7 | builder.use Her::Middleware::FirstLevelParseJSON 8 | end 9 | 10 | spawn_model "Foo::User" 11 | end 12 | 13 | context :before_save do 14 | subject { Foo::User.create(name: "Tobias Funke") } 15 | before do 16 | Her::API.default_api.connection.adapter :test do |stub| 17 | stub.post("/users") { |env| [200, {}, { id: 1, name: env[:body][:name] }.to_json] } 18 | stub.put("/users/1") { |env| [200, {}, { id: 1, name: env[:body][:name] }.to_json] } 19 | end 20 | end 21 | 22 | context "when using a symbol callback" do 23 | before do 24 | class Foo::User 25 | before_save :alter_name 26 | def alter_name 27 | name.upcase! 28 | end 29 | end 30 | end 31 | 32 | describe "#name" do 33 | subject { super().name } 34 | it { is_expected.to eq("TOBIAS FUNKE") } 35 | end 36 | end 37 | 38 | context "when using a block callback" do 39 | before do 40 | class Foo::User 41 | before_save -> { name.upcase! } 42 | end 43 | end 44 | 45 | describe "#name" do 46 | subject { super().name } 47 | it { is_expected.to eq("TOBIAS FUNKE") } 48 | end 49 | end 50 | 51 | context "when changing a value of an existing resource in a callback" do 52 | before do 53 | class Foo::User 54 | before_save :alter_name 55 | def alter_name 56 | self.name = "Lumberjack" if persisted? 57 | end 58 | end 59 | end 60 | 61 | it "should call the server with the canged value" do 62 | expect(subject.name).to eq("Tobias Funke") 63 | subject.save 64 | expect(subject.name).to eq("Lumberjack") 65 | end 66 | end 67 | end 68 | 69 | context :before_create do 70 | subject { Foo::User.create(name: "Tobias Funke") } 71 | before do 72 | Her::API.default_api.connection.adapter :test do |stub| 73 | stub.post("/users") { |env| [200, {}, { id: 1, name: env[:body][:name] }.to_json] } 74 | end 75 | end 76 | 77 | context "when using a symbol callback" do 78 | before do 79 | class Foo::User 80 | before_create :alter_name 81 | def alter_name 82 | name.upcase! 83 | end 84 | end 85 | end 86 | 87 | describe "#name" do 88 | subject { super().name } 89 | it { is_expected.to eq("TOBIAS FUNKE") } 90 | end 91 | end 92 | 93 | context "when using a block callback" do 94 | before do 95 | class Foo::User 96 | before_create -> { name.upcase! } 97 | end 98 | end 99 | 100 | describe "#name" do 101 | subject { super().name } 102 | it { is_expected.to eq("TOBIAS FUNKE") } 103 | end 104 | end 105 | end 106 | 107 | context :after_find do 108 | subject { Foo::User.find(1) } 109 | before do 110 | Her::API.default_api.connection.adapter :test do |stub| 111 | stub.get("/users/1") { [200, {}, { id: 1, name: "Tobias Funke" }.to_json] } 112 | end 113 | end 114 | 115 | context "when using a symbol callback" do 116 | before do 117 | class Foo::User 118 | after_find :alter_name 119 | def alter_name 120 | name.upcase! 121 | end 122 | end 123 | end 124 | 125 | describe "#name" do 126 | subject { super().name } 127 | it { is_expected.to eq("TOBIAS FUNKE") } 128 | end 129 | end 130 | 131 | context "when using a block callback" do 132 | before do 133 | class Foo::User 134 | after_find -> { name.upcase! } 135 | end 136 | end 137 | 138 | describe "#name" do 139 | subject { super().name } 140 | it { is_expected.to eq("TOBIAS FUNKE") } 141 | end 142 | end 143 | end 144 | 145 | context :after_initialize do 146 | subject { Foo::User.new(name: "Tobias Funke") } 147 | 148 | context "when using a symbol callback" do 149 | before do 150 | class Foo::User 151 | after_initialize :alter_name 152 | def alter_name 153 | name.upcase! 154 | end 155 | end 156 | end 157 | 158 | describe "#name" do 159 | subject { super().name } 160 | it { is_expected.to eq("TOBIAS FUNKE") } 161 | end 162 | end 163 | 164 | context "when using a block callback" do 165 | before do 166 | class Foo::User 167 | after_initialize -> { name.upcase! } 168 | end 169 | end 170 | 171 | describe "#name" do 172 | subject { super().name } 173 | it { is_expected.to eq("TOBIAS FUNKE") } 174 | end 175 | end 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /spec/model/nested_attributes_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.join(File.dirname(__FILE__), "../spec_helper.rb") 3 | 4 | describe Her::Model::NestedAttributes do 5 | context "with a belongs_to association" do 6 | before do 7 | Her::API.setup url: "https://api.example.com" do |builder| 8 | builder.use Her::Middleware::FirstLevelParseJSON 9 | builder.use Faraday::Request::UrlEncoded 10 | end 11 | 12 | spawn_model "Foo::User" do 13 | belongs_to :company, path: "/organizations/:id", foreign_key: :organization_id 14 | accepts_nested_attributes_for :company 15 | end 16 | 17 | spawn_model "Foo::Company" 18 | 19 | @user_with_data_through_nested_attributes = Foo::User.new name: "Test", company_attributes: { name: "Example Company" } 20 | end 21 | 22 | context "when child does not yet exist" do 23 | it "creates an instance of the associated class" do 24 | expect(@user_with_data_through_nested_attributes.company).to be_a(Foo::Company) 25 | expect(@user_with_data_through_nested_attributes.company.name).to eq("Example Company") 26 | end 27 | end 28 | 29 | context "when child does exist" do 30 | it "updates the attributes of the associated object" do 31 | @user_with_data_through_nested_attributes.company_attributes = { name: "Fünke's Company" } 32 | expect(@user_with_data_through_nested_attributes.company).to be_a(Foo::Company) 33 | expect(@user_with_data_through_nested_attributes.company.name).to eq("Fünke's Company") 34 | end 35 | end 36 | end 37 | 38 | context "with a has_one association" do 39 | before do 40 | Her::API.setup url: "https://api.example.com" do |builder| 41 | builder.use Her::Middleware::FirstLevelParseJSON 42 | builder.use Faraday::Request::UrlEncoded 43 | end 44 | 45 | spawn_model "Foo::User" do 46 | has_one :pet 47 | accepts_nested_attributes_for :pet 48 | end 49 | 50 | spawn_model "Foo::Pet" 51 | 52 | @user_with_data_through_nested_attributes = Foo::User.new name: "Test", pet_attributes: { name: "Hasi" } 53 | end 54 | 55 | context "when child does not yet exist" do 56 | it "creates an instance of the associated class" do 57 | expect(@user_with_data_through_nested_attributes.pet).to be_a(Foo::Pet) 58 | expect(@user_with_data_through_nested_attributes.pet.name).to eq("Hasi") 59 | end 60 | end 61 | 62 | context "when child does exist" do 63 | it "updates the attributes of the associated object" do 64 | @user_with_data_through_nested_attributes.pet_attributes = { name: "Rodriguez" } 65 | expect(@user_with_data_through_nested_attributes.pet).to be_a(Foo::Pet) 66 | expect(@user_with_data_through_nested_attributes.pet.name).to eq("Rodriguez") 67 | end 68 | end 69 | end 70 | 71 | context "with a has_many association" do 72 | before do 73 | Her::API.setup url: "https://api.example.com" do |builder| 74 | builder.use Her::Middleware::FirstLevelParseJSON 75 | builder.use Faraday::Request::UrlEncoded 76 | end 77 | 78 | spawn_model "Foo::User" do 79 | has_many :pets 80 | accepts_nested_attributes_for :pets 81 | end 82 | 83 | spawn_model "Foo::Pet" 84 | 85 | @user_with_data_through_nested_attributes = Foo::User.new name: "Test", pets_attributes: [{ name: "Hasi" }, { name: "Rodriguez" }] 86 | end 87 | 88 | context "when children do not yet exist" do 89 | it "creates an instance of the associated class" do 90 | expect(@user_with_data_through_nested_attributes.pets.length).to eq(2) 91 | expect(@user_with_data_through_nested_attributes.pets[0]).to be_a(Foo::Pet) 92 | expect(@user_with_data_through_nested_attributes.pets[1]).to be_a(Foo::Pet) 93 | expect(@user_with_data_through_nested_attributes.pets[0].name).to eq("Hasi") 94 | expect(@user_with_data_through_nested_attributes.pets[1].name).to eq("Rodriguez") 95 | end 96 | end 97 | end 98 | 99 | context "with a has_many association as a Hash" do 100 | before do 101 | Her::API.setup url: "https://api.example.com" do |builder| 102 | builder.use Her::Middleware::FirstLevelParseJSON 103 | builder.use Faraday::Request::UrlEncoded 104 | end 105 | 106 | spawn_model "Foo::User" do 107 | has_many :pets 108 | accepts_nested_attributes_for :pets 109 | end 110 | 111 | spawn_model "Foo::Pet" 112 | 113 | @user_with_data_through_nested_attributes_as_hash = Foo::User.new name: "Test", pets_attributes: { "0" => { name: "Hasi" }, "1" => { name: "Rodriguez" } } 114 | end 115 | 116 | context "when children do not yet exist" do 117 | it "creates an instance of the associated class" do 118 | expect(@user_with_data_through_nested_attributes_as_hash.pets.length).to eq(2) 119 | expect(@user_with_data_through_nested_attributes_as_hash.pets[0]).to be_a(Foo::Pet) 120 | expect(@user_with_data_through_nested_attributes_as_hash.pets[1]).to be_a(Foo::Pet) 121 | expect(@user_with_data_through_nested_attributes_as_hash.pets[0].name).to eq("Hasi") 122 | expect(@user_with_data_through_nested_attributes_as_hash.pets[1].name).to eq("Rodriguez") 123 | end 124 | end 125 | end 126 | 127 | context "with an unknown association" do 128 | it "raises an error" do 129 | expect do 130 | spawn_model("Foo::User") { accepts_nested_attributes_for :company } 131 | end.to raise_error(Her::Errors::AssociationUnknownError, "Unknown association name :company") 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/her/model/associations.rb: -------------------------------------------------------------------------------- 1 | require "her/model/associations/association" 2 | require "her/model/associations/association_proxy" 3 | require "her/model/associations/belongs_to_association" 4 | require "her/model/associations/has_many_association" 5 | require "her/model/associations/has_one_association" 6 | 7 | module Her 8 | module Model 9 | # This module adds associations to models 10 | module Associations 11 | extend ActiveSupport::Concern 12 | 13 | # Returns true if the model has a association_name association, false otherwise. 14 | # 15 | # @private 16 | def has_association?(association_name) 17 | associations = self.class.associations.values.flatten.map { |r| r[:name] } 18 | associations.include?(association_name) 19 | end 20 | 21 | # Returns the resource/collection corresponding to the association_name association. 22 | # 23 | # @private 24 | def get_association(association_name) 25 | send(association_name) if has_association?(association_name) 26 | end 27 | 28 | module ClassMethods 29 | # Return @_her_associations, lazily initialized with copy of the 30 | # superclass' her_associations, or an empty hash. 31 | # 32 | # @private 33 | def associations 34 | @_her_associations ||= begin 35 | superclass.respond_to?(:associations) ? superclass.associations.dup : Hash.new { |h,k| h[k] = [] } 36 | end 37 | end 38 | 39 | # @private 40 | def association_names 41 | associations.inject([]) { |memo, (name, details)| memo << details }.flatten.map { |a| a[:name] } 42 | end 43 | 44 | # @private 45 | def association_keys 46 | associations.inject([]) { |memo, (name, details)| memo << details }.flatten.map { |a| a[:data_key] } 47 | end 48 | 49 | # Parse associations data after initializing a new object 50 | # 51 | # @private 52 | def parse_associations(data) 53 | associations.each_pair do |type, definitions| 54 | definitions.each do |association| 55 | association_class = "her/model/associations/#{type}_association".classify.constantize 56 | data.merge! association_class.parse(association, self, data) 57 | end 58 | end 59 | 60 | data 61 | end 62 | 63 | # Define an *has_many* association. 64 | # 65 | # @param [Symbol] name The name of the method added to resources 66 | # @param [Hash] opts Options 67 | # @option opts [String] :class_name The name of the class to map objects to 68 | # @option opts [Symbol] :data_key The attribute where the data is stored 69 | # @option opts [Path] :path The relative path where to fetch the data (defaults to `/{name}`) 70 | # 71 | # @example 72 | # class User 73 | # include Her::Model 74 | # has_many :articles 75 | # end 76 | # 77 | # class Article 78 | # include Her::Model 79 | # end 80 | # 81 | # @user = User.find(1) 82 | # @user.articles # => [#] 83 | # # Fetched via GET "/users/1/articles" 84 | def has_many(name, opts={}) 85 | Her::Model::Associations::HasManyAssociation.attach(self, name, opts) 86 | end 87 | 88 | # Define an *has_one* association. 89 | # 90 | # @param [Symbol] name The name of the method added to resources 91 | # @param [Hash] opts Options 92 | # @option opts [String] :class_name The name of the class to map objects to 93 | # @option opts [Symbol] :data_key The attribute where the data is stored 94 | # @option opts [Path] :path The relative path where to fetch the data (defaults to `/{name}`) 95 | # 96 | # @example 97 | # class User 98 | # include Her::Model 99 | # has_one :organization 100 | # end 101 | # 102 | # class Organization 103 | # include Her::Model 104 | # end 105 | # 106 | # @user = User.find(1) 107 | # @user.organization # => # 108 | # # Fetched via GET "/users/1/organization" 109 | def has_one(name, opts={}) 110 | Her::Model::Associations::HasOneAssociation.attach(self, name, opts) 111 | end 112 | 113 | # Define a *belongs_to* association. 114 | # 115 | # @param [Symbol] name The name of the method added to resources 116 | # @param [Hash] opts Options 117 | # @option opts [String] :class_name The name of the class to map objects to 118 | # @option opts [Symbol] :data_key The attribute where the data is stored 119 | # @option opts [Path] :path The relative path where to fetch the data (defaults to `/{class_name}.pluralize/{id}`) 120 | # @option opts [Symbol] :foreign_key The foreign key used to build the `:id` part of the path (defaults to `{name}_id`) 121 | # 122 | # @example 123 | # class User 124 | # include Her::Model 125 | # belongs_to :team, :class_name => "Group" 126 | # end 127 | # 128 | # class Group 129 | # include Her::Model 130 | # end 131 | # 132 | # @user = User.find(1) # => # 133 | # @user.team # => # 134 | # # Fetched via GET "/teams/2" 135 | def belongs_to(name, opts={}) 136 | Her::Model::Associations::BelongsToAssociation.attach(self, name, opts) 137 | end 138 | end 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /spec/model/http_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.join(File.dirname(__FILE__), "../spec_helper.rb") 3 | 4 | describe Her::Model::HTTP do 5 | context "binding a model with an API" do 6 | let(:api1) { Her::API.new url: "https://api1.example.com" } 7 | let(:api2) { Her::API.new url: "https://api2.example.com" } 8 | 9 | before do 10 | spawn_model("Foo::User") 11 | spawn_model("Foo::Comment") 12 | Her::API.setup url: "https://api.example.com" 13 | end 14 | 15 | context "when binding a model to its superclass' her_api" do 16 | before do 17 | spawn_model "Foo::Superclass" 18 | Foo::Superclass.uses_api api1 19 | Foo::Subclass = Class.new(Foo::Superclass) 20 | end 21 | 22 | specify { expect(Foo::Subclass.her_api).to eq(Foo::Superclass.her_api) } 23 | end 24 | 25 | context "when changing her_api without changing the parent class' her_api" do 26 | before do 27 | spawn_model "Foo::Superclass" 28 | Foo::Subclass = Class.new(Foo::Superclass) 29 | Foo::Superclass.uses_api api1 30 | Foo::Subclass.uses_api api2 31 | end 32 | 33 | specify { expect(Foo::Subclass.her_api).not_to eq(Foo::Superclass.her_api) } 34 | end 35 | end 36 | 37 | context "making HTTP requests" do 38 | before do 39 | Her::API.setup url: "https://api.example.com" do |builder| 40 | builder.use Her::Middleware::FirstLevelParseJSON 41 | builder.use Faraday::Request::UrlEncoded 42 | builder.adapter :test do |stub| 43 | stub.get("/users") { [200, {}, [{ id: 1 }].to_json] } 44 | stub.get("/users/1") { [200, {}, { id: 1 }.to_json] } 45 | stub.get("/users/popular") do |env| 46 | if env[:params]["page"] == "2" 47 | [200, {}, [{ id: 3 }, { id: 4 }].to_json] 48 | else 49 | [200, {}, [{ id: 1 }, { id: 2 }].to_json] 50 | end 51 | end 52 | end 53 | end 54 | 55 | spawn_model "Foo::User" 56 | end 57 | 58 | describe :get do 59 | subject { Foo::User.get(:popular) } 60 | 61 | describe "#length" do 62 | subject { super().length } 63 | it { is_expected.to eq(2) } 64 | end 65 | specify { expect(subject.first.id).to eq(1) } 66 | end 67 | 68 | describe :get_raw do 69 | context "with a block" do 70 | specify do 71 | Foo::User.get_raw("/users") do |parsed_data, _response| 72 | expect(parsed_data[:data]).to eq([{ id: 1 }]) 73 | end 74 | end 75 | end 76 | 77 | context "with a return value" do 78 | subject { Foo::User.get_raw("/users") } 79 | specify { expect(subject[:parsed_data][:data]).to eq([{ id: 1 }]) } 80 | end 81 | end 82 | 83 | describe :get_collection do 84 | context "with a String path" do 85 | subject { Foo::User.get_collection("/users/popular") } 86 | 87 | describe "#length" do 88 | subject { super().length } 89 | it { is_expected.to eq(2) } 90 | end 91 | specify { expect(subject.first.id).to eq(1) } 92 | end 93 | 94 | context "with a Symbol" do 95 | subject { Foo::User.get_collection(:popular) } 96 | 97 | describe "#length" do 98 | subject { super().length } 99 | it { is_expected.to eq(2) } 100 | end 101 | specify { expect(subject.first.id).to eq(1) } 102 | end 103 | 104 | context "with extra parameters" do 105 | subject { Foo::User.get_collection(:popular, page: 2) } 106 | 107 | describe "#length" do 108 | subject { super().length } 109 | it { is_expected.to eq(2) } 110 | end 111 | specify { expect(subject.first.id).to eq(3) } 112 | end 113 | end 114 | 115 | describe :get_resource do 116 | context "with a String path" do 117 | subject { Foo::User.get_resource("/users/1") } 118 | 119 | describe "#id" do 120 | subject { super().id } 121 | it { is_expected.to eq(1) } 122 | end 123 | end 124 | 125 | context "with a Symbol" do 126 | subject { Foo::User.get_resource(:"1") } 127 | 128 | describe "#id" do 129 | subject { super().id } 130 | it { is_expected.to eq(1) } 131 | end 132 | end 133 | end 134 | 135 | describe :get_raw do 136 | specify do 137 | Foo::User.get_raw(:popular) do |parsed_data, _response| 138 | expect(parsed_data[:data]).to eq([{ id: 1 }, { id: 2 }]) 139 | end 140 | end 141 | end 142 | end 143 | 144 | context "setting custom HTTP requests" do 145 | before do 146 | Her::API.setup url: "https://api.example.com" do |connection| 147 | connection.use Her::Middleware::FirstLevelParseJSON 148 | connection.adapter :test do |stub| 149 | stub.get("/users/popular") { [200, {}, [{ id: 1 }, { id: 2 }].to_json] } 150 | stub.post("/users/from_default") { [200, {}, { id: 4 }.to_json] } 151 | end 152 | end 153 | 154 | spawn_model "Foo::User" 155 | end 156 | 157 | subject { Foo::User } 158 | 159 | describe :custom_get do 160 | context "without cache" do 161 | before { Foo::User.custom_get :popular, :recent } 162 | it { is_expected.to respond_to(:popular) } 163 | it { is_expected.to respond_to(:recent) } 164 | 165 | context "making the HTTP request" do 166 | subject { Foo::User.popular } 167 | 168 | describe "#length" do 169 | subject { super().length } 170 | it { is_expected.to eq(2) } 171 | end 172 | end 173 | end 174 | end 175 | 176 | describe :custom_post do 177 | before { Foo::User.custom_post :from_default } 178 | it { is_expected.to respond_to(:from_default) } 179 | 180 | context "making the HTTP request" do 181 | subject { Foo::User.from_default(name: "Tobias Fünke") } 182 | 183 | describe "#id" do 184 | subject { super().id } 185 | it { is_expected.to eq(4) } 186 | end 187 | end 188 | end 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /lib/her/model/relation.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Model 3 | class Relation 4 | # @private 5 | attr_accessor :params 6 | 7 | # @private 8 | def initialize(parent) 9 | @parent = parent 10 | @params = {} 11 | end 12 | 13 | # @private 14 | def apply_to(attributes) 15 | @params.merge(attributes) 16 | end 17 | 18 | # Build a new resource 19 | def build(attributes = {}) 20 | @parent.build(@params.merge(attributes)) 21 | end 22 | 23 | # Add a query string parameter 24 | # 25 | # @example 26 | # @users = User.all 27 | # # Fetched via GET "/users" 28 | # 29 | # @example 30 | # @users = User.where(:approved => 1).all 31 | # # Fetched via GET "/users?approved=1" 32 | def where(params = {}) 33 | return self if params.blank? && !@_fetch.nil? 34 | self.clone.tap do |r| 35 | r.params = r.params.merge(params) 36 | r.clear_fetch_cache! 37 | end 38 | end 39 | alias all where 40 | 41 | # Bubble all methods to the fetched collection 42 | # 43 | # @private 44 | def method_missing(method, *args, &blk) 45 | fetch.send(method, *args, &blk) 46 | end 47 | 48 | # @private 49 | def respond_to?(method, *args) 50 | super || fetch.respond_to?(method, *args) 51 | end 52 | 53 | # @private 54 | def nil? 55 | fetch.nil? 56 | end 57 | 58 | # @private 59 | def kind_of?(thing) 60 | fetch.kind_of?(thing) 61 | end 62 | 63 | # Fetch a collection of resources 64 | # 65 | # @private 66 | def fetch 67 | @_fetch ||= begin 68 | path = @parent.build_request_path(@params) 69 | method = @parent.method_for(:find) 70 | @parent.request(@params.merge(:_method => method, :_path => path)) do |parsed_data, response| 71 | @parent.new_collection(parsed_data) 72 | end 73 | end 74 | end 75 | 76 | # Fetch specific resource(s) by their ID 77 | # 78 | # @example 79 | # @user = User.find(1) 80 | # # Fetched via GET "/users/1" 81 | # 82 | # @example 83 | # @users = User.find([1, 2]) 84 | # # Fetched via GET "/users/1" and GET "/users/2" 85 | def find(*ids) 86 | params = @params.merge(ids.last.is_a?(Hash) ? ids.pop : {}) 87 | ids = Array(params[@parent.primary_key]) if params.key?(@parent.primary_key) 88 | 89 | results = ids.flatten.compact.uniq.map do |id| 90 | resource = nil 91 | request_params = params.merge( 92 | :_method => @parent.method_for(:find), 93 | :_path => @parent.build_request_path(params.merge(@parent.primary_key => id)) 94 | ) 95 | 96 | @parent.request(request_params) do |parsed_data, response| 97 | if response.success? 98 | resource = @parent.new_from_parsed_data(parsed_data) 99 | resource.instance_variable_set(:@changed_attributes, {}) 100 | resource.run_callbacks :find 101 | else 102 | return nil 103 | end 104 | end 105 | 106 | resource 107 | end 108 | 109 | ids.length > 1 || ids.first.kind_of?(Array) ? results : results.first 110 | end 111 | 112 | # Fetch first resource with the given attributes. 113 | # 114 | # If no resource is found, returns nil. 115 | # 116 | # @example 117 | # @user = User.find_by(name: "Tobias", age: 42) 118 | # # Called via GET "/users?name=Tobias&age=42" 119 | def find_by(params) 120 | where(params).first 121 | end 122 | 123 | # Fetch first resource with the given attributes, or create a resource 124 | # with the attributes if one is not found. 125 | # 126 | # @example 127 | # @user = User.find_or_create_by(email: "remi@example.com") 128 | # 129 | # # Returns the first item in the collection if present: 130 | # # Called via GET "/users?email=remi@example.com" 131 | # 132 | # # If collection is empty: 133 | # # POST /users with `email=remi@example.com` 134 | # @user.email # => "remi@example.com" 135 | # @user.new? # => false 136 | def find_or_create_by(attributes) 137 | find_by(attributes) || create(attributes) 138 | end 139 | 140 | # Fetch first resource with the given attributes, or initialize a resource 141 | # with the attributes if one is not found. 142 | # 143 | # @example 144 | # @user = User.find_or_initialize_by(email: "remi@example.com") 145 | # 146 | # # Returns the first item in the collection if present: 147 | # # Called via GET "/users?email=remi@example.com" 148 | # 149 | # # If collection is empty: 150 | # @user.email # => "remi@example.com" 151 | # @user.new? # => true 152 | def find_or_initialize_by(attributes) 153 | find_by(attributes) || build(attributes) 154 | end 155 | 156 | # Create a resource and return it 157 | # 158 | # @example 159 | # @user = User.create(:fullname => "Tobias Fünke") 160 | # # Called via POST "/users/1" with `&fullname=Tobias+Fünke` 161 | # 162 | # @example 163 | # @user = User.where(:email => "tobias@bluth.com").create(:fullname => "Tobias Fünke") 164 | # # Called via POST "/users/1" with `&email=tobias@bluth.com&fullname=Tobias+Fünke` 165 | def create(attributes = {}) 166 | attributes ||= {} 167 | resource = @parent.new(@params.merge(attributes)) 168 | resource.save 169 | 170 | resource 171 | end 172 | 173 | # Fetch a resource and create it if it's not found 174 | # 175 | # @example 176 | # @user = User.where(:email => "remi@example.com").find_or_create 177 | # 178 | # # Returns the first item of the collection if present: 179 | # # GET "/users?email=remi@example.com" 180 | # 181 | # # If collection is empty: 182 | # # POST /users with `email=remi@example.com` 183 | def first_or_create(attributes = {}) 184 | fetch.first || create(attributes) 185 | end 186 | 187 | # Fetch a resource and build it if it's not found 188 | # 189 | # @example 190 | # @user = User.where(:email => "remi@example.com").find_or_initialize 191 | # 192 | # # Returns the first item of the collection if present: 193 | # # GET "/users?email=remi@example.com" 194 | # 195 | # # If collection is empty: 196 | # @user.email # => "remi@example.com" 197 | # @user.new? # => true 198 | def first_or_initialize(attributes = {}) 199 | fetch.first || build(attributes) 200 | end 201 | 202 | # @private 203 | def clear_fetch_cache! 204 | instance_variable_set(:@_fetch, nil) 205 | end 206 | end 207 | end 208 | end 209 | -------------------------------------------------------------------------------- /lib/her/model/parse.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Model 3 | # This module handles resource data parsing at the model level (after the parsing middleware) 4 | module Parse 5 | extend ActiveSupport::Concern 6 | 7 | # Convert into a hash of request parameters, based on `include_root_in_json`. 8 | # 9 | # @example 10 | # @user.to_params 11 | # # => { :id => 1, :name => 'John Smith' } 12 | def to_params 13 | self.class.to_params(self.attributes, self.changes) 14 | end 15 | 16 | module ClassMethods 17 | # Parse data before assigning it to a resource, based on `parse_root_in_json`. 18 | # 19 | # @param [Hash] data 20 | # @private 21 | def parse(data) 22 | if parse_root_in_json? && root_element_included?(data) 23 | if json_api_format? 24 | data.fetch(parsed_root_element).first 25 | else 26 | data.fetch(parsed_root_element) { data } 27 | end 28 | else 29 | data 30 | end 31 | end 32 | 33 | # @private 34 | def to_params(attributes, changes={}) 35 | filtered_attributes = attributes.dup.symbolize_keys 36 | filtered_attributes.merge!(embeded_params(attributes)) 37 | if her_api.options[:send_only_modified_attributes] 38 | filtered_attributes = changes.symbolize_keys.keys.inject({}) do |hash, attribute| 39 | hash[attribute] = filtered_attributes[attribute] 40 | hash 41 | end 42 | end 43 | 44 | if include_root_in_json? 45 | if json_api_format? 46 | { included_root_element => [filtered_attributes] } 47 | else 48 | { included_root_element => filtered_attributes } 49 | end 50 | else 51 | filtered_attributes 52 | end 53 | end 54 | 55 | 56 | # @private 57 | # TODO: Handle has_one 58 | def embeded_params(attributes) 59 | associations[:has_many].select { |a| attributes.include?(a[:data_key])}.compact.inject({}) do |hash, association| 60 | params = attributes[association[:data_key]].map(&:to_params) 61 | next hash if params.empty? 62 | if association[:class_name].constantize.include_root_in_json? 63 | root = association[:class_name].constantize.root_element 64 | hash[association[:data_key]] = params.map { |n| n[root] } 65 | else 66 | hash[association[:data_key]] = params 67 | end 68 | hash 69 | end 70 | end 71 | 72 | # Return or change the value of `include_root_in_json` 73 | # 74 | # @example 75 | # class User 76 | # include Her::Model 77 | # include_root_in_json true 78 | # end 79 | def include_root_in_json(value, options = {}) 80 | @_her_include_root_in_json = value 81 | @_her_include_root_in_json_format = options[:format] 82 | end 83 | 84 | # Return or change the value of `parse_root_in_json` 85 | # 86 | # @example 87 | # class User 88 | # include Her::Model 89 | # parse_root_in_json true 90 | # end 91 | # 92 | # class User 93 | # include Her::Model 94 | # parse_root_in_json true, format: :active_model_serializers 95 | # end 96 | # 97 | # class User 98 | # include Her::Model 99 | # parse_root_in_json true, format: :json_api 100 | # end 101 | def parse_root_in_json(value, options = {}) 102 | @_her_parse_root_in_json = value 103 | @_her_parse_root_in_json_format = options[:format] 104 | end 105 | 106 | # Return or change the value of `request_new_object_on_build` 107 | # 108 | # @example 109 | # class User 110 | # include Her::Model 111 | # request_new_object_on_build true 112 | # end 113 | def request_new_object_on_build(value = nil) 114 | @_her_request_new_object_on_build = value 115 | end 116 | 117 | # Return or change the value of `root_element`. Always defaults to the base name of the class. 118 | # 119 | # @example 120 | # class User 121 | # include Her::Model 122 | # parse_root_in_json true 123 | # root_element :huh 124 | # end 125 | # 126 | # user = User.find(1) # { :huh => { :id => 1, :name => "Tobias" } } 127 | # user.name # => "Tobias" 128 | def root_element(value = nil) 129 | if value.nil? 130 | if json_api_format? 131 | @_her_root_element ||= self.name.split("::").last.pluralize.underscore.to_sym 132 | else 133 | @_her_root_element ||= self.name.split("::").last.underscore.to_sym 134 | end 135 | else 136 | @_her_root_element = value.to_sym 137 | end 138 | end 139 | 140 | # @private 141 | def root_element_included?(data) 142 | data.keys.to_s.include? @_her_root_element.to_s 143 | end 144 | 145 | # @private 146 | def included_root_element 147 | include_root_in_json? == true ? root_element : include_root_in_json? 148 | end 149 | 150 | # Extract an array from the request data 151 | # 152 | # @example 153 | # # with parse_root_in_json true, :format => :active_model_serializers 154 | # class User 155 | # include Her::Model 156 | # parse_root_in_json true, :format => :active_model_serializers 157 | # end 158 | # 159 | # users = User.all # { :users => [ { :id => 1, :name => "Tobias" } ] } 160 | # users.first.name # => "Tobias" 161 | # 162 | # # without parse_root_in_json 163 | # class User 164 | # include Her::Model 165 | # end 166 | # 167 | # users = User.all # [ { :id => 1, :name => "Tobias" } ] 168 | # users.first.name # => "Tobias" 169 | # 170 | # @private 171 | def extract_array(request_data) 172 | if request_data[:data].is_a?(Hash) && (active_model_serializers_format? || json_api_format?) 173 | request_data[:data][pluralized_parsed_root_element] 174 | else 175 | request_data[:data] 176 | end 177 | end 178 | 179 | # @private 180 | def pluralized_parsed_root_element 181 | parsed_root_element.to_s.pluralize.to_sym 182 | end 183 | 184 | # @private 185 | def parsed_root_element 186 | parse_root_in_json? == true ? root_element : parse_root_in_json? 187 | end 188 | 189 | # @private 190 | def active_model_serializers_format? 191 | @_her_parse_root_in_json_format == :active_model_serializers || (superclass.respond_to?(:active_model_serializers_format?) && superclass.active_model_serializers_format?) 192 | end 193 | 194 | # @private 195 | def json_api_format? 196 | @_her_parse_root_in_json_format == :json_api || (superclass.respond_to?(:json_api_format?) && superclass.json_api_format?) 197 | end 198 | 199 | # @private 200 | def request_new_object_on_build? 201 | @_her_request_new_object_on_build || (superclass.respond_to?(:request_new_object_on_build?) && superclass.request_new_object_on_build?) 202 | end 203 | 204 | # @private 205 | def include_root_in_json? 206 | @_her_include_root_in_json || (superclass.respond_to?(:include_root_in_json?) && superclass.include_root_in_json?) 207 | end 208 | 209 | # @private 210 | def parse_root_in_json? 211 | @_her_parse_root_in_json || (superclass.respond_to?(:parse_root_in_json?) && superclass.parse_root_in_json?) 212 | end 213 | end 214 | end 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /spec/model/relation_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.join(File.dirname(__FILE__), "../spec_helper.rb") 3 | 4 | describe Her::Model::Relation do 5 | describe :where do 6 | context "for base classes" do 7 | before do 8 | Her::API.setup url: "https://api.example.com" do |builder| 9 | builder.use Her::Middleware::FirstLevelParseJSON 10 | builder.adapter :test do |stub| 11 | stub.get("/users?foo=1&bar=2") { ok! [{ id: 2, fullname: "Tobias Fünke" }] } 12 | stub.get("/users?admin=1") { ok! [{ id: 1, fullname: "Tobias Fünke" }] } 13 | 14 | stub.get("/users") do 15 | ok! [ 16 | { id: 1, fullname: "Tobias Fünke" }, 17 | { id: 2, fullname: "Lindsay Fünke" }, 18 | @created_user 19 | ].compact 20 | end 21 | 22 | stub.post("/users") do 23 | @created_user = { id: 3, fullname: "George Michael Bluth" } 24 | ok! @created_user 25 | end 26 | end 27 | end 28 | 29 | spawn_model "Foo::User" 30 | end 31 | 32 | it "doesn't fetch the data immediatly" do 33 | expect(Foo::User).to receive(:request).never 34 | @users = Foo::User.where(admin: 1) 35 | end 36 | 37 | it "fetches the data and passes query parameters" do 38 | expect(Foo::User).to receive(:request).once.and_call_original 39 | @users = Foo::User.where(admin: 1) 40 | expect(@users).to respond_to(:length) 41 | expect(@users.size).to eql 1 42 | end 43 | 44 | it "chains multiple where statements" do 45 | @user = Foo::User.where(foo: 1).where(bar: 2).first 46 | expect(@user.id).to eq(2) 47 | end 48 | 49 | it "does not reuse relations" do 50 | expect(Foo::User.all.size).to eql 2 51 | expect(Foo::User.create(fullname: "George Michael Bluth").id).to eq(3) 52 | expect(Foo::User.all.size).to eql 3 53 | end 54 | end 55 | 56 | context "for parent class" do 57 | before do 58 | Her::API.setup url: "https://api.example.com" do |builder| 59 | builder.use Her::Middleware::FirstLevelParseJSON 60 | builder.adapter :test do |stub| 61 | stub.get("/users?page=2") { ok! [{ id: 1, fullname: "Tobias Fünke" }, { id: 2, fullname: "Lindsay Fünke" }] } 62 | end 63 | end 64 | 65 | spawn_model("Foo::Model") do 66 | scope :page, ->(page) { where(page: page) } 67 | end 68 | 69 | class User < Foo::Model; end 70 | @spawned_models << :User 71 | end 72 | 73 | it "propagates the scopes through its children" do 74 | @users = User.page(2) 75 | expect(@users.length).to eq(2) 76 | end 77 | end 78 | end 79 | 80 | describe :create do 81 | before do 82 | Her::API.setup url: "https://api.example.com" do |builder| 83 | builder.use Her::Middleware::FirstLevelParseJSON 84 | builder.use Faraday::Request::UrlEncoded 85 | builder.adapter :test do |stub| 86 | stub.post("/users") { |env| ok! id: 1, fullname: params(env)[:fullname], email: params(env)[:email] } 87 | end 88 | end 89 | 90 | spawn_model "Foo::User" 91 | end 92 | 93 | context "with a single where call" do 94 | it "creates a resource and passes the query parameters" do 95 | @user = Foo::User.where(fullname: "Tobias Fünke", email: "tobias@bluth.com").create 96 | expect(@user.id).to eq(1) 97 | expect(@user.fullname).to eq("Tobias Fünke") 98 | expect(@user.email).to eq("tobias@bluth.com") 99 | end 100 | end 101 | 102 | context "with multiple where calls" do 103 | it "creates a resource and passes the query parameters" do 104 | @user = Foo::User.where(fullname: "Tobias Fünke").create(email: "tobias@bluth.com") 105 | expect(@user.id).to eq(1) 106 | expect(@user.fullname).to eq("Tobias Fünke") 107 | expect(@user.email).to eq("tobias@bluth.com") 108 | end 109 | end 110 | end 111 | 112 | describe :build do 113 | before { spawn_model "Foo::User" } 114 | 115 | it "handles new resource with build" do 116 | @new_user = Foo::User.where(fullname: "Tobias Fünke").build 117 | expect(@new_user.new?).to be_truthy 118 | expect(@new_user.fullname).to eq("Tobias Fünke") 119 | end 120 | end 121 | 122 | describe :scope do 123 | before do 124 | Her::API.setup url: "https://api.example.com" do |builder| 125 | builder.use Her::Middleware::FirstLevelParseJSON 126 | builder.adapter :test do |stub| 127 | stub.get("/users?what=4&where=3") { ok! [{ id: 3, fullname: "Maeby Fünke" }] } 128 | stub.get("/users?what=2") { ok! [{ id: 2, fullname: "Lindsay Fünke" }] } 129 | stub.get("/users?where=6") { ok! [{ id: 4, fullname: "Tobias Fünke" }] } 130 | end 131 | end 132 | 133 | spawn_model "Foo::User" do 134 | scope :foo, ->(v) { where(what: v) } 135 | scope :bar, ->(v) { where(where: v) } 136 | scope :baz, -> { bar(6) } 137 | end 138 | end 139 | 140 | it "passes query parameters" do 141 | @user = Foo::User.foo(2).first 142 | expect(@user.id).to eq(2) 143 | end 144 | 145 | it "passes multiple query parameters" do 146 | @user = Foo::User.foo(4).bar(3).first 147 | expect(@user.id).to eq(3) 148 | end 149 | 150 | it "handles embedded scopes" do 151 | @user = Foo::User.baz.first 152 | expect(@user.id).to eq(4) 153 | end 154 | end 155 | 156 | describe :default_scope do 157 | context "for new objects" do 158 | before do 159 | spawn_model "Foo::User" do 160 | default_scope -> { where(active: true) } 161 | default_scope -> { where(admin: true) } 162 | end 163 | end 164 | 165 | it "should apply the scope to the attributes" do 166 | expect(Foo::User.new).to be_active 167 | expect(Foo::User.new).to be_admin 168 | end 169 | end 170 | 171 | context "for fetched resources" do 172 | before do 173 | Her::API.setup url: "https://api.example.com" do |builder| 174 | builder.use Her::Middleware::FirstLevelParseJSON 175 | builder.use Faraday::Request::UrlEncoded 176 | builder.adapter :test do |stub| 177 | stub.post("/users") { |env| ok! id: 3, active: (params(env)[:active] == "true" ? true : false) } 178 | end 179 | end 180 | 181 | spawn_model "Foo::User" do 182 | default_scope -> { where(active: true) } 183 | end 184 | end 185 | 186 | it("should apply the scope to the request") { expect(Foo::User.create).to be_active } 187 | end 188 | 189 | context "for fetched collections" do 190 | before do 191 | Her::API.setup url: "https://api.example.com" do |builder| 192 | builder.use Her::Middleware::FirstLevelParseJSON 193 | builder.use Faraday::Request::UrlEncoded 194 | builder.adapter :test do |stub| 195 | stub.get("/users?active=true") { |env| ok! [{ id: 3, active: (params(env)[:active] == "true" ? true : false) }] } 196 | end 197 | end 198 | 199 | spawn_model "Foo::User" do 200 | default_scope -> { where(active: true) } 201 | end 202 | end 203 | 204 | it("should apply the scope to the request") { expect(Foo::User.all.first).to be_active } 205 | end 206 | end 207 | 208 | describe :map do 209 | before do 210 | Her::API.setup url: "https://api.example.com" do |builder| 211 | builder.use Her::Middleware::FirstLevelParseJSON 212 | builder.adapter :test do |stub| 213 | stub.get("/users") do 214 | ok! [{ id: 1, fullname: "Tobias Fünke" }, { id: 2, fullname: "Lindsay Fünke" }] 215 | end 216 | end 217 | end 218 | 219 | spawn_model "Foo::User" 220 | end 221 | 222 | it "delegates the method to the fetched collection" do 223 | expect(Foo::User.all.map(&:fullname)).to eq(["Tobias Fünke", "Lindsay Fünke"]) 224 | end 225 | end 226 | end 227 | -------------------------------------------------------------------------------- /lib/her/model/attributes.rb: -------------------------------------------------------------------------------- 1 | module Her 2 | module Model 3 | # This module handles all methods related to model attributes 4 | module Attributes 5 | extend ActiveSupport::Concern 6 | 7 | # Initialize a new object with data 8 | # 9 | # @param [Hash] attributes The attributes to initialize the object with 10 | # @option attributes [Hash,Array] :_metadata 11 | # @option attributes [Hash,Array] :_errors 12 | # @option attributes [Boolean] :_destroyed 13 | # 14 | # @example 15 | # class User 16 | # include Her::Model 17 | # end 18 | # 19 | # User.new(name: "Tobias") # => # 20 | def initialize(attributes={}) 21 | attributes ||= {} 22 | @metadata = attributes.delete(:_metadata) || {} 23 | @response_errors = attributes.delete(:_errors) || {} 24 | @destroyed = attributes.delete(:_destroyed) || false 25 | 26 | attributes = self.class.default_scope.apply_to(attributes) 27 | assign_attributes(attributes) 28 | run_callbacks :initialize 29 | end 30 | 31 | # Initialize a collection of resources 32 | # 33 | # @private 34 | def self.initialize_collection(klass, parsed_data={}) 35 | collection_data = klass.extract_array(parsed_data).map do |item_data| 36 | if item_data.kind_of?(klass) 37 | resource = item_data 38 | else 39 | resource = klass.new(klass.parse(item_data)) 40 | resource.run_callbacks :find 41 | end 42 | resource 43 | end 44 | Her::Collection.new(collection_data, parsed_data[:metadata], parsed_data[:errors]) 45 | end 46 | 47 | # Use setter methods of model for each key / value pair in params 48 | # Return key / value pairs for which no setter method was defined on the model 49 | # 50 | # @private 51 | def self.use_setter_methods(model, params) 52 | params ||= {} 53 | 54 | reserved_keys = [:id, model.class.primary_key] + model.class.association_keys 55 | model.class.attributes *params.keys.reject { |k| reserved_keys.include?(k) || reserved_keys.map(&:to_s).include?(k) } 56 | 57 | setter_method_names = model.class.setter_method_names 58 | params.inject({}) do |memo, (key, value)| 59 | setter_method = key.to_s + '=' 60 | if setter_method_names.include?(setter_method) 61 | model.send(setter_method, value) 62 | else 63 | key = key.to_sym if key.is_a?(String) 64 | memo[key] = value 65 | end 66 | memo 67 | end 68 | end 69 | 70 | # Handles missing methods 71 | # 72 | # @private 73 | def method_missing(method, *args, &blk) 74 | if method.to_s =~ /[?=]$/ || @attributes.include?(method) 75 | # Extract the attribute 76 | attribute = method.to_s.sub(/[?=]$/, '') 77 | 78 | # Create a new `attribute` methods set 79 | self.class.attributes(*attribute) 80 | 81 | # Resend the method! 82 | send(method, *args, &blk) 83 | else 84 | super 85 | end 86 | end 87 | 88 | # @private 89 | def respond_to_missing?(method, include_private = false) 90 | method.to_s.end_with?('=') || method.to_s.end_with?('?') || @attributes.include?(method) || super 91 | end 92 | 93 | # Assign new attributes to a resource 94 | # 95 | # @example 96 | # class User 97 | # include Her::Model 98 | # end 99 | # 100 | # user = User.find(1) # => # 101 | # user.assign_attributes(name: "Lindsay") 102 | # user.changes # => { :name => ["Tobias", "Lindsay"] } 103 | def assign_attributes(new_attributes) 104 | @attributes ||= attributes 105 | # Use setter methods first 106 | unset_attributes = Her::Model::Attributes.use_setter_methods(self, new_attributes) 107 | 108 | # Then translate attributes of associations into association instances 109 | parsed_attributes = self.class.parse_associations(unset_attributes) 110 | 111 | # Then merge the parsed_data into @attributes. 112 | @attributes.merge!(parsed_attributes) 113 | end 114 | alias attributes= assign_attributes 115 | 116 | def attributes 117 | @attributes ||= HashWithIndifferentAccess.new 118 | end 119 | 120 | # Handles returning true for the accessible attributes 121 | # 122 | # @private 123 | def has_attribute?(attribute_name) 124 | @attributes.include?(attribute_name) 125 | end 126 | 127 | # Handles returning data for a specific attribute 128 | # 129 | # @private 130 | def get_attribute(attribute_name) 131 | @attributes[attribute_name] 132 | end 133 | alias attribute get_attribute 134 | 135 | # Return the value of the model `primary_key` attribute 136 | def id 137 | @attributes[self.class.primary_key] 138 | end 139 | 140 | # Return `true` if the other object is also a Her::Model and has matching data 141 | # 142 | # @private 143 | def ==(other) 144 | other.is_a?(Her::Model) && @attributes == other.attributes 145 | end 146 | 147 | # Delegate to the == method 148 | # 149 | # @private 150 | def eql?(other) 151 | self == other 152 | end 153 | 154 | # Delegate to @attributes, allowing models to act correctly in code like: 155 | # [ Model.find(1), Model.find(1) ].uniq # => [ Model.find(1) ] 156 | # @private 157 | def hash 158 | @attributes.hash 159 | end 160 | 161 | # Assign attribute value (ActiveModel convention method). 162 | # 163 | # @private 164 | def attribute=(attribute, value) 165 | @attributes[attribute] = nil unless @attributes.include?(attribute) 166 | self.send(:"#{attribute}_will_change!") if @attributes[attribute] != value 167 | @attributes[attribute] = value 168 | end 169 | 170 | # Check attribute value to be present (ActiveModel convention method). 171 | # 172 | # @private 173 | def attribute?(attribute) 174 | @attributes.include?(attribute) && @attributes[attribute].present? 175 | end 176 | 177 | module ClassMethods 178 | # Initialize a collection of resources with raw data from an HTTP request 179 | # 180 | # @param [Array] parsed_data 181 | # @private 182 | def new_collection(parsed_data) 183 | Her::Model::Attributes.initialize_collection(self, parsed_data) 184 | end 185 | 186 | # Initialize a new object with the "raw" parsed_data from the parsing middleware 187 | # 188 | # @private 189 | def new_from_parsed_data(parsed_data) 190 | parsed_data = parsed_data.with_indifferent_access 191 | new(parse(parsed_data[:data]).merge :_metadata => parsed_data[:metadata], :_errors => parsed_data[:errors]) 192 | end 193 | 194 | # Define attribute method matchers to automatically define them using ActiveModel's define_attribute_methods. 195 | # 196 | # @private 197 | def define_attribute_method_matchers 198 | attribute_method_suffix '=' 199 | attribute_method_suffix '?' 200 | end 201 | 202 | # Create a mutex for dynamically generated attribute methods or use one defined by ActiveModel. 203 | # 204 | # @private 205 | def attribute_methods_mutex 206 | @attribute_methods_mutex ||= if generated_attribute_methods.respond_to? :mu_synchronize 207 | generated_attribute_methods 208 | else 209 | Mutex.new 210 | end 211 | end 212 | 213 | # Define the attributes that will be used to track dirty attributes and validations 214 | # 215 | # @param [Array] attributes 216 | # @example 217 | # class User 218 | # include Her::Model 219 | # attributes :name, :email 220 | # end 221 | def attributes(*attributes) 222 | attribute_methods_mutex.synchronize do 223 | define_attribute_methods attributes 224 | end 225 | end 226 | 227 | # Define the accessor in which the API response errors (obtained from the parsing middleware) will be stored 228 | # 229 | # @param [Symbol] store_response_errors 230 | # 231 | # @example 232 | # class User 233 | # include Her::Model 234 | # store_response_errors :server_errors 235 | # end 236 | def store_response_errors(value = nil) 237 | store_her_data(:response_errors, value) 238 | end 239 | 240 | # Define the accessor in which the API response metadata (obtained from the parsing middleware) will be stored 241 | # 242 | # @param [Symbol] store_metadata 243 | # 244 | # @example 245 | # class User 246 | # include Her::Model 247 | # store_metadata :server_data 248 | # end 249 | def store_metadata(value = nil) 250 | store_her_data(:metadata, value) 251 | end 252 | 253 | # @private 254 | def setter_method_names 255 | @_her_setter_method_names ||= instance_methods.inject(Set.new) do |memo, method_name| 256 | memo << method_name.to_s if method_name.to_s.end_with?('=') 257 | memo 258 | end 259 | end 260 | 261 | private 262 | # @private 263 | def store_her_data(name, value) 264 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 265 | if @_her_store_#{name} && value.present? 266 | remove_method @_her_store_#{name}.to_sym 267 | remove_method @_her_store_#{name}.to_s + '=' 268 | end 269 | 270 | @_her_store_#{name} ||= begin 271 | superclass.store_#{name} if superclass.respond_to?(:store_#{name}) 272 | end 273 | 274 | return @_her_store_#{name} unless value 275 | @_her_store_#{name} = value 276 | 277 | define_method(value) { @#{name} } 278 | define_method(value.to_s+'=') { |value| @#{name} = value } 279 | RUBY 280 | end 281 | end 282 | end 283 | end 284 | end 285 | -------------------------------------------------------------------------------- /lib/her/model/orm.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | module Her 3 | module Model 4 | # This module adds ORM-like capabilities to the model 5 | module ORM 6 | extend ActiveSupport::Concern 7 | 8 | # Return `true` if a resource was not saved yet 9 | def new? 10 | id.nil? 11 | end 12 | alias new_record? new? 13 | 14 | # Return `true` if a resource is not `#new?` 15 | def persisted? 16 | !new? 17 | end 18 | 19 | # Return whether the object has been destroyed 20 | def destroyed? 21 | @destroyed == true 22 | end 23 | 24 | # Save a resource and return `false` if the response is not a successful one or 25 | # if there are errors in the resource. Otherwise, return the newly updated resource 26 | # 27 | # @example Save a resource after fetching it 28 | # @user = User.find(1) 29 | # # Fetched via GET "/users/1" 30 | # @user.fullname = "Tobias Fünke" 31 | # @user.save 32 | # # Called via PUT "/users/1" 33 | # 34 | # @example Save a new resource by creating it 35 | # @user = User.new({ :fullname => "Tobias Fünke" }) 36 | # @user.save 37 | # # Called via POST "/users" 38 | def save 39 | callback = new? ? :create : :update 40 | method = self.class.method_for(callback) 41 | 42 | run_callbacks callback do 43 | run_callbacks :save do 44 | params = to_params 45 | self.class.request(to_params.merge(:_method => method, :_path => request_path)) do |parsed_data, response| 46 | assign_attributes(self.class.parse(parsed_data[:data])) if parsed_data[:data].any? 47 | @metadata = parsed_data[:metadata] 48 | @response_errors = parsed_data[:errors] 49 | 50 | return false if !response.success? || @response_errors.any? 51 | if self.changed_attributes.present? 52 | @previously_changed = self.changed_attributes.clone 53 | self.changed_attributes.clear 54 | end 55 | end 56 | end 57 | end 58 | 59 | self 60 | end 61 | 62 | # Similar to save(), except that ResourceInvalid is raised if the save fails 63 | def save! 64 | if !self.save 65 | raise Her::Errors::ResourceInvalid, self 66 | end 67 | self 68 | end 69 | 70 | # Destroy a resource 71 | # 72 | # @example 73 | # @user = User.find(1) 74 | # @user.destroy 75 | # # Called via DELETE "/users/1" 76 | def destroy(params = {}) 77 | method = self.class.method_for(:destroy) 78 | run_callbacks :destroy do 79 | self.class.request(params.merge(:_method => method, :_path => request_path)) do |parsed_data, response| 80 | assign_attributes(self.class.parse(parsed_data[:data])) if parsed_data[:data].any? 81 | @metadata = parsed_data[:metadata] 82 | @response_errors = parsed_data[:errors] 83 | @destroyed = response.success? 84 | end 85 | end 86 | self 87 | end 88 | 89 | # Refetches the resource 90 | # 91 | # This method finds the resource by its primary key (which could be 92 | # assigned manually) and modifies the object in-place. 93 | # 94 | # @example 95 | # user = User.find(1) 96 | # # => # 97 | # user.name = "Oops" 98 | # user.reload # Fetched again via GET "/users/1" 99 | # # => # 100 | def reload(options = nil) 101 | fresh_object = self.class.find(id) 102 | assign_attributes(fresh_object.attributes) 103 | self 104 | end 105 | 106 | # Assigns to +attribute+ the boolean opposite of attribute?. So 107 | # if the predicate returns +true+ the attribute will become +false+. This 108 | # method toggles directly the underlying value without calling any setter. 109 | # Returns +self+. 110 | # 111 | # @example 112 | # user = User.first 113 | # user.admin? # => false 114 | # user.toggle(:admin) 115 | # user.admin? # => true 116 | def toggle(attribute) 117 | attributes[attribute] = !public_send("#{attribute}?") 118 | self 119 | end 120 | 121 | # Wrapper around #toggle that saves the resource. Saving is subjected to 122 | # validation checks. Returns +true+ if the record could be saved. 123 | def toggle!(attribute) 124 | toggle(attribute) && save 125 | end 126 | 127 | # Initializes +attribute+ to zero if +nil+ and adds the value passed as 128 | # +by+ (default is 1). The increment is performed directly on the 129 | # underlying attribute, no setter is invoked. Only makes sense for 130 | # number-based attributes. Returns +self+. 131 | def increment(attribute, by = 1) 132 | attributes[attribute] ||= 0 133 | attributes[attribute] += by 134 | self 135 | end 136 | 137 | # Wrapper around #increment that saves the resource. Saving is subjected 138 | # to validation checks. Returns +self+. 139 | def increment!(attribute, by = 1) 140 | increment(attribute, by) && save 141 | self 142 | end 143 | 144 | # Initializes +attribute+ to zero if +nil+ and substracts the value passed as 145 | # +by+ (default is 1). The decrement is performed directly on the 146 | # underlying attribute, no setter is invoked. Only makes sense for 147 | # number-based attributes. Returns +self+. 148 | def decrement(attribute, by = 1) 149 | increment(attribute, -by) 150 | end 151 | 152 | # Wrapper around #decrement that saves the resource. Saving is subjected 153 | # to validation checks. Returns +self+. 154 | def decrement!(attribute, by = 1) 155 | increment!(attribute, -by) 156 | end 157 | 158 | module ClassMethods 159 | # Create a new chainable scope 160 | # 161 | # @example 162 | # class User 163 | # include Her::Model 164 | # 165 | # scope :admins, lambda { where(:admin => 1) } 166 | # scope :page, lambda { |page| where(:page => page) } 167 | # enc 168 | # 169 | # User.admins # Called via GET "/users?admin=1" 170 | # User.page(2).all # Called via GET "/users?page=2" 171 | def scope(name, code) 172 | # Add the scope method to the class 173 | (class << self; self end).send(:define_method, name) do |*args| 174 | instance_exec(*args, &code) 175 | end 176 | 177 | # Add the scope method to the Relation class 178 | Relation.instance_eval do 179 | define_method(name) { |*args| instance_exec(*args, &code) } 180 | end 181 | end 182 | 183 | # @private 184 | def scoped 185 | @_her_default_scope || blank_relation 186 | end 187 | 188 | # Define the default scope for the model 189 | # 190 | # @example 191 | # class User 192 | # include Her::Model 193 | # 194 | # default_scope lambda { where(:admin => 1) } 195 | # enc 196 | # 197 | # User.all # Called via GET "/users?admin=1" 198 | # User.new.admin # => 1 199 | def default_scope(block=nil) 200 | @_her_default_scope ||= (!respond_to?(:default_scope) && superclass.respond_to?(:default_scope)) ? superclass.default_scope : scoped 201 | @_her_default_scope = @_her_default_scope.instance_exec(&block) unless block.nil? 202 | @_her_default_scope 203 | end 204 | 205 | # Delegate the following methods to `scoped` 206 | [:all, :where, :create, :build, :find, :find_by, :find_or_create_by, 207 | :find_or_initialize_by, :first_or_create, :first_or_initialize].each do |method| 208 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 209 | def #{method}(*params) 210 | scoped.send(#{method.to_sym.inspect}, *params) 211 | end 212 | RUBY 213 | end 214 | 215 | # Save an existing resource and return it 216 | # 217 | # @example 218 | # @user = User.save_existing(1, { :fullname => "Tobias Fünke" }) 219 | # # Called via PUT "/users/1" 220 | def save_existing(id, params) 221 | resource = new(params.merge(primary_key => id)) 222 | resource.save 223 | resource 224 | end 225 | 226 | # Destroy an existing resource 227 | # 228 | # @example 229 | # User.destroy_existing(1) 230 | # # Called via DELETE "/users/1" 231 | def destroy_existing(id, params={}) 232 | request(params.merge(:_method => method_for(:destroy), :_path => build_request_path(params.merge(primary_key => id)))) do |parsed_data, response| 233 | data = parse(parsed_data[:data]) 234 | metadata = parsed_data[:metadata] 235 | response_errors = parsed_data[:errors] 236 | new(data.merge(:_destroyed => response.success?, :metadata => metadata, :response_errors => response_errors)) 237 | end 238 | end 239 | 240 | # Return or change the HTTP method used to create or update records 241 | # 242 | # @param [Symbol, String] action The behavior in question (`:create` or `:update`) 243 | # @param [Symbol, String] method The HTTP method to use (`'PUT'`, `:post`, etc.) 244 | def method_for(action = nil, method = nil) 245 | @method_for ||= (superclass.respond_to?(:method_for) ? superclass.method_for : {}) 246 | return @method_for if action.nil? 247 | 248 | action = action.to_s.downcase.to_sym 249 | 250 | return @method_for[action] if method.nil? 251 | @method_for[action] = method.to_s.downcase.to_sym 252 | end 253 | 254 | # Build a new resource with the given attributes. 255 | # If the request_new_object_on_build flag is set, the new object is requested via API. 256 | def build(attributes = {}) 257 | params = attributes 258 | return self.new(params) unless self.request_new_object_on_build? 259 | 260 | path = self.build_request_path(params.merge(self.primary_key => 'new')) 261 | method = self.method_for(:new) 262 | 263 | resource = nil 264 | self.request(params.merge(:_method => method, :_path => path)) do |parsed_data, response| 265 | if response.success? 266 | resource = self.new_from_parsed_data(parsed_data) 267 | end 268 | end 269 | resource 270 | end 271 | 272 | private 273 | # @private 274 | def blank_relation 275 | @blank_relation ||= Relation.new(self) 276 | end 277 | end 278 | end 279 | end 280 | end 281 | -------------------------------------------------------------------------------- /spec/model/parse_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.join(File.dirname(__FILE__), "../spec_helper.rb") 3 | 4 | describe Her::Model::Parse do 5 | context "when include_root_in_json is set" do 6 | before do 7 | Her::API.setup url: "https://api.example.com" do |builder| 8 | builder.use Her::Middleware::FirstLevelParseJSON 9 | builder.use Faraday::Request::UrlEncoded 10 | end 11 | 12 | Her::API.default_api.connection.adapter :test do |stub| 13 | stub.post("/users") { |env| [200, {}, { user: { id: 1, fullname: params(env)[:user][:fullname] } }.to_json] } 14 | stub.post("/users/admins") { |env| [200, {}, { user: { id: 1, fullname: params(env)[:user][:fullname] } }.to_json] } 15 | end 16 | end 17 | 18 | context "to true" do 19 | before do 20 | spawn_model "Foo::User" do 21 | include_root_in_json true 22 | parse_root_in_json true 23 | custom_post :admins 24 | end 25 | end 26 | 27 | it "wraps params in the element name in `to_params`" do 28 | @new_user = Foo::User.new(fullname: "Tobias Fünke") 29 | expect(@new_user.to_params).to eq(user: { fullname: "Tobias Fünke" }) 30 | end 31 | 32 | it "wraps params in the element name in `.create`" do 33 | @new_user = Foo::User.admins(fullname: "Tobias Fünke") 34 | expect(@new_user.fullname).to eq("Tobias Fünke") 35 | end 36 | end 37 | 38 | context "to a symbol" do 39 | before do 40 | spawn_model "Foo::User" do 41 | include_root_in_json :person 42 | parse_root_in_json :person 43 | end 44 | end 45 | 46 | it "wraps params in the specified value" do 47 | @new_user = Foo::User.new(fullname: "Tobias Fünke") 48 | expect(@new_user.to_params).to eq(person: { fullname: "Tobias Fünke" }) 49 | end 50 | end 51 | 52 | context "in the parent class" do 53 | before do 54 | spawn_model("Foo::Model") { include_root_in_json true } 55 | 56 | class User < Foo::Model; end 57 | @spawned_models << :User 58 | end 59 | 60 | it "wraps params with the class name" do 61 | @new_user = User.new(fullname: "Tobias Fünke") 62 | expect(@new_user.to_params).to eq(user: { fullname: "Tobias Fünke" }) 63 | end 64 | end 65 | end 66 | 67 | context "when parse_root_in_json is set" do 68 | before do 69 | Her::API.setup url: "https://api.example.com" do |builder| 70 | builder.use Her::Middleware::FirstLevelParseJSON 71 | builder.use Faraday::Request::UrlEncoded 72 | end 73 | end 74 | 75 | context "to true" do 76 | before do 77 | Her::API.default_api.connection.adapter :test do |stub| 78 | stub.post("/users") { [200, {}, { user: { id: 1, fullname: "Lindsay Fünke" } }.to_json] } 79 | stub.get("/users") { [200, {}, [{ user: { id: 1, fullname: "Lindsay Fünke" } }].to_json] } 80 | stub.get("/users/admins") { [200, {}, [{ user: { id: 1, fullname: "Lindsay Fünke" } }].to_json] } 81 | stub.get("/users/1") { [200, {}, { user: { id: 1, fullname: "Lindsay Fünke" } }.to_json] } 82 | stub.put("/users/1") { [200, {}, { user: { id: 1, fullname: "Tobias Fünke Jr." } }.to_json] } 83 | end 84 | 85 | spawn_model("Foo::User") do 86 | parse_root_in_json true 87 | custom_get :admins 88 | end 89 | end 90 | 91 | it "parse the data from the JSON root element after .create" do 92 | @new_user = Foo::User.create(fullname: "Lindsay Fünke") 93 | expect(@new_user.fullname).to eq("Lindsay Fünke") 94 | end 95 | 96 | it "parse the data from the JSON root element after an arbitrary HTTP request" do 97 | @new_user = Foo::User.admins 98 | expect(@new_user.first.fullname).to eq("Lindsay Fünke") 99 | end 100 | 101 | it "parse the data from the JSON root element after .all" do 102 | @users = Foo::User.all 103 | expect(@users.first.fullname).to eq("Lindsay Fünke") 104 | end 105 | 106 | it "parse the data from the JSON root element after .find" do 107 | @user = Foo::User.find(1) 108 | expect(@user.fullname).to eq("Lindsay Fünke") 109 | end 110 | 111 | it "parse the data from the JSON root element after .save" do 112 | @user = Foo::User.find(1) 113 | @user.fullname = "Tobias Fünke" 114 | @user.save 115 | expect(@user.fullname).to eq("Tobias Fünke Jr.") 116 | end 117 | end 118 | 119 | context "to a symbol" do 120 | before do 121 | Her::API.default_api.connection.adapter :test do |stub| 122 | stub.post("/users") { [200, {}, { person: { id: 1, fullname: "Lindsay Fünke" } }.to_json] } 123 | end 124 | 125 | spawn_model("Foo::User") { parse_root_in_json :person } 126 | end 127 | 128 | it "parse the data with the symbol" do 129 | @new_user = Foo::User.create(fullname: "Lindsay Fünke") 130 | expect(@new_user.fullname).to eq("Lindsay Fünke") 131 | end 132 | end 133 | 134 | context "in the parent class" do 135 | before do 136 | Her::API.default_api.connection.adapter :test do |stub| 137 | stub.post("/users") { [200, {}, { user: { id: 1, fullname: "Lindsay Fünke" } }.to_json] } 138 | stub.get("/users") { [200, {}, { users: [{ id: 1, fullname: "Lindsay Fünke" }] }.to_json] } 139 | end 140 | 141 | spawn_model("Foo::Model") { parse_root_in_json true, format: :active_model_serializers } 142 | class User < Foo::Model 143 | collection_path "/users" 144 | end 145 | 146 | @spawned_models << :User 147 | end 148 | 149 | it "parse the data with the symbol" do 150 | @new_user = User.create(fullname: "Lindsay Fünke") 151 | expect(@new_user.fullname).to eq("Lindsay Fünke") 152 | end 153 | 154 | it "parses the collection of data" do 155 | @users = User.all 156 | expect(@users.first.fullname).to eq("Lindsay Fünke") 157 | end 158 | end 159 | 160 | context "to true with format: :active_model_serializers" do 161 | before do 162 | Her::API.default_api.connection.adapter :test do |stub| 163 | stub.post("/users") { [200, {}, { user: { id: 1, fullname: "Lindsay Fünke" } }.to_json] } 164 | stub.get("/users") { [200, {}, { users: [{ id: 1, fullname: "Lindsay Fünke" }] }.to_json] } 165 | stub.get("/users/admins") { [200, {}, { users: [{ id: 1, fullname: "Lindsay Fünke" }] }.to_json] } 166 | stub.get("/users/1") { [200, {}, { user: { id: 1, fullname: "Lindsay Fünke" } }.to_json] } 167 | stub.put("/users/1") { [200, {}, { user: { id: 1, fullname: "Tobias Fünke Jr." } }.to_json] } 168 | end 169 | 170 | spawn_model("Foo::User") do 171 | parse_root_in_json true, format: :active_model_serializers 172 | custom_get :admins 173 | end 174 | end 175 | 176 | it "parse the data from the JSON root element after .create" do 177 | @new_user = Foo::User.create(fullname: "Lindsay Fünke") 178 | expect(@new_user.fullname).to eq("Lindsay Fünke") 179 | end 180 | 181 | it "parse the data from the JSON root element after an arbitrary HTTP request" do 182 | @users = Foo::User.admins 183 | expect(@users.first.fullname).to eq("Lindsay Fünke") 184 | end 185 | 186 | it "parse the data from the JSON root element after .all" do 187 | @users = Foo::User.all 188 | expect(@users.first.fullname).to eq("Lindsay Fünke") 189 | end 190 | 191 | it "parse the data from the JSON root element after .find" do 192 | @user = Foo::User.find(1) 193 | expect(@user.fullname).to eq("Lindsay Fünke") 194 | end 195 | 196 | it "parse the data from the JSON root element after .save" do 197 | @user = Foo::User.find(1) 198 | @user.fullname = "Tobias Fünke" 199 | @user.save 200 | expect(@user.fullname).to eq("Tobias Fünke Jr.") 201 | end 202 | end 203 | end 204 | 205 | context "when to_params is set" do 206 | before do 207 | Her::API.setup url: "https://api.example.com" do |builder| 208 | builder.use Her::Middleware::FirstLevelParseJSON 209 | builder.use Faraday::Request::UrlEncoded 210 | builder.adapter :test do |stub| 211 | stub.post("/users") { |env| ok! id: 1, fullname: params(env)["fullname"] } 212 | end 213 | end 214 | 215 | spawn_model "Foo::User" do 216 | def to_params 217 | { fullname: "Lindsay Fünke" } 218 | end 219 | end 220 | end 221 | 222 | it "changes the request parameters for one-line resource creation" do 223 | @user = Foo::User.create(fullname: "Tobias Fünke") 224 | expect(@user.fullname).to eq("Lindsay Fünke") 225 | end 226 | 227 | it "changes the request parameters for Model.new + #save" do 228 | @user = Foo::User.new(fullname: "Tobias Fünke") 229 | @user.save 230 | expect(@user.fullname).to eq("Lindsay Fünke") 231 | end 232 | end 233 | 234 | context "when parse_root_in_json set json_api to true" do 235 | before do 236 | Her::API.setup url: "https://api.example.com" do |builder| 237 | builder.use Her::Middleware::FirstLevelParseJSON 238 | builder.use Faraday::Request::UrlEncoded 239 | builder.adapter :test do |stub| 240 | stub.get("/users") { [200, {}, { users: [{ id: 1, fullname: "Lindsay Fünke" }] }.to_json] } 241 | stub.get("/users/admins") { [200, {}, { users: [{ id: 1, fullname: "Lindsay Fünke" }] }.to_json] } 242 | stub.get("/users/1") { [200, {}, { users: [{ id: 1, fullname: "Lindsay Fünke" }] }.to_json] } 243 | stub.post("/users") { [200, {}, { users: [{ fullname: "Lindsay Fünke" }] }.to_json] } 244 | stub.put("/users/1") { [200, {}, { users: [{ id: 1, fullname: "Tobias Fünke Jr." }] }.to_json] } 245 | end 246 | end 247 | 248 | spawn_model("Foo::User") do 249 | parse_root_in_json true, format: :json_api 250 | include_root_in_json true 251 | custom_get :admins 252 | end 253 | end 254 | 255 | it "parse the data from the JSON root element after .create" do 256 | @new_user = Foo::User.create(fullname: "Lindsay Fünke") 257 | expect(@new_user.fullname).to eq("Lindsay Fünke") 258 | end 259 | 260 | it "parse the data from the JSON root element after an arbitrary HTTP request" do 261 | @new_user = Foo::User.admins 262 | expect(@new_user.first.fullname).to eq("Lindsay Fünke") 263 | end 264 | 265 | it "parse the data from the JSON root element after .all" do 266 | @users = Foo::User.all 267 | expect(@users.first.fullname).to eq("Lindsay Fünke") 268 | end 269 | 270 | it "parse the data from the JSON root element after .find" do 271 | @user = Foo::User.find(1) 272 | expect(@user.fullname).to eq("Lindsay Fünke") 273 | end 274 | 275 | it "parse the data from the JSON root element after .save" do 276 | @user = Foo::User.find(1) 277 | @user.fullname = "Tobias Fünke" 278 | @user.save 279 | expect(@user.fullname).to eq("Tobias Fünke Jr.") 280 | end 281 | 282 | it "parse the data from the JSON root element after new/save" do 283 | @user = Foo::User.new 284 | @user.fullname = "Lindsay Fünke (before save)" 285 | @user.save 286 | expect(@user.fullname).to eq("Lindsay Fünke") 287 | end 288 | end 289 | 290 | context "when include_root_in_json set json_api" do 291 | before do 292 | Her::API.setup url: "https://api.example.com" do |builder| 293 | builder.use Her::Middleware::FirstLevelParseJSON 294 | builder.use Faraday::Request::UrlEncoded 295 | end 296 | 297 | Her::API.default_api.connection.adapter :test do |stub| 298 | stub.post("/users") { |env| [200, {}, { users: [{ id: 1, fullname: params(env)[:users][:fullname] }] }.to_json] } 299 | end 300 | end 301 | 302 | context "to true" do 303 | before do 304 | spawn_model "Foo::User" do 305 | include_root_in_json true 306 | parse_root_in_json true, format: :json_api 307 | custom_post :admins 308 | end 309 | end 310 | 311 | it "wraps params in the element name in `to_params`" do 312 | @new_user = Foo::User.new(fullname: "Tobias Fünke") 313 | expect(@new_user.to_params).to eq(users: [{ fullname: "Tobias Fünke" }]) 314 | end 315 | 316 | it "wraps params in the element name in `.where`" do 317 | @new_user = Foo::User.where(fullname: "Tobias Fünke").build 318 | expect(@new_user.fullname).to eq("Tobias Fünke") 319 | end 320 | end 321 | end 322 | 323 | context "when send_only_modified_attributes is set" do 324 | before do 325 | Her::API.setup url: "https://api.example.com", send_only_modified_attributes: true do |builder| 326 | builder.use Her::Middleware::FirstLevelParseJSON 327 | builder.use Faraday::Request::UrlEncoded 328 | end 329 | 330 | Her::API.default_api.connection.adapter :test do |stub| 331 | stub.get("/users/1") { [200, {}, { id: 1, first_name: "Gooby", last_name: "Pls" }.to_json] } 332 | end 333 | 334 | spawn_model "Foo::User" do 335 | include_root_in_json true 336 | end 337 | end 338 | 339 | it "only sends the attributes that were modified" do 340 | user = Foo::User.find 1 341 | user.first_name = "Someone" 342 | expect(user.to_params).to eql(user: { first_name: "Someone" }) 343 | end 344 | end 345 | end 346 | -------------------------------------------------------------------------------- /spec/model/attributes_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.join(File.dirname(__FILE__), "../spec_helper.rb") 3 | 4 | describe Her::Model::Attributes do 5 | context "mapping data to Ruby objects" do 6 | before { spawn_model "Foo::User" } 7 | 8 | it "handles new resource" do 9 | @new_user = Foo::User.new(fullname: "Tobias Fünke") 10 | expect(@new_user.new?).to be_truthy 11 | expect(@new_user.fullname).to eq("Tobias Fünke") 12 | end 13 | 14 | it "accepts new resource with strings as hash keys" do 15 | @new_user = Foo::User.new("fullname" => "Tobias Fünke") 16 | expect(@new_user.fullname).to eq("Tobias Fünke") 17 | end 18 | 19 | it "handles method missing for getter" do 20 | @new_user = Foo::User.new(fullname: "Mayonegg") 21 | expect { @new_user.unknown_method_for_a_user }.to raise_error(NoMethodError) 22 | expect { @new_user.fullname }.not_to raise_error 23 | end 24 | 25 | it "handles method missing for setter" do 26 | @new_user = Foo::User.new 27 | expect { @new_user.fullname = "Tobias Fünke" }.not_to raise_error 28 | end 29 | 30 | it "handles method missing for query" do 31 | @new_user = Foo::User.new 32 | expect { @new_user.fullname? }.not_to raise_error 33 | end 34 | 35 | it "handles respond_to for getter" do 36 | @new_user = Foo::User.new(fullname: "Mayonegg") 37 | expect(@new_user).not_to respond_to(:unknown_method_for_a_user) 38 | expect(@new_user).to respond_to(:fullname) 39 | end 40 | 41 | it "handles respond_to for setter" do 42 | @new_user = Foo::User.new 43 | expect(@new_user).to respond_to(:fullname=) 44 | end 45 | 46 | it "handles respond_to for query" do 47 | @new_user = Foo::User.new 48 | expect(@new_user).to respond_to(:fullname?) 49 | end 50 | 51 | it "handles has_attribute? for getter" do 52 | @new_user = Foo::User.new(fullname: "Mayonegg") 53 | expect(@new_user).not_to have_attribute(:unknown_method_for_a_user) 54 | expect(@new_user).to have_attribute(:fullname) 55 | end 56 | 57 | it "handles get_attribute for getter" do 58 | @new_user = Foo::User.new(fullname: "Mayonegg") 59 | expect(@new_user.get_attribute(:unknown_method_for_a_user)).to be_nil 60 | expect(@new_user.get_attribute(:fullname)).to eq("Mayonegg") 61 | end 62 | 63 | it "handles get_attribute for getter with dash" do 64 | @new_user = Foo::User.new(:'life-span' => "3 years") 65 | expect(@new_user.get_attribute(:unknown_method_for_a_user)).to be_nil 66 | expect(@new_user.get_attribute(:'life-span')).to eq("3 years") 67 | end 68 | end 69 | 70 | context "assigning new resource data" do 71 | before do 72 | spawn_model "Foo::User" 73 | @user = Foo::User.new(active: false) 74 | end 75 | 76 | it "handles data update through #assign_attributes" do 77 | @user.assign_attributes active: true 78 | expect(@user).to be_active 79 | end 80 | end 81 | 82 | context "checking resource equality" do 83 | before do 84 | Her::API.setup url: "https://api.example.com" do |builder| 85 | builder.use Her::Middleware::FirstLevelParseJSON 86 | builder.use Faraday::Request::UrlEncoded 87 | builder.adapter :test do |stub| 88 | stub.get("/users/1") { [200, {}, { id: 1, fullname: "Lindsay Fünke" }.to_json] } 89 | stub.get("/users/2") { [200, {}, { id: 1, fullname: "Tobias Fünke" }.to_json] } 90 | stub.get("/admins/1") { [200, {}, { id: 1, fullname: "Lindsay Fünke" }.to_json] } 91 | end 92 | end 93 | 94 | spawn_model "Foo::User" 95 | spawn_model "Foo::Admin" 96 | end 97 | 98 | let(:user) { Foo::User.find(1) } 99 | 100 | it "returns true for the exact same object" do 101 | expect(user).to eq(user) 102 | end 103 | 104 | it "returns true for the same resource via find" do 105 | expect(user).to eq(Foo::User.find(1)) 106 | end 107 | 108 | it "returns true for the same class with identical data" do 109 | expect(user).to eq(Foo::User.new(id: 1, fullname: "Lindsay Fünke")) 110 | end 111 | 112 | it "returns true for a different resource with the same data" do 113 | expect(user).to eq(Foo::Admin.find(1)) 114 | end 115 | 116 | it "returns false for the same class with different data" do 117 | expect(user).not_to eq(Foo::User.new(id: 2, fullname: "Tobias Fünke")) 118 | end 119 | 120 | it "returns false for a non-resource with the same data" do 121 | fake_user = double(data: { id: 1, fullname: "Lindsay Fünke" }) 122 | expect(user).not_to eq(fake_user) 123 | end 124 | 125 | it "delegates eql? to ==" do 126 | other = Object.new 127 | expect(user).to receive(:==).with(other).and_return(true) 128 | expect(user.eql?(other)).to be_truthy 129 | end 130 | 131 | it "treats equal resources as equal for Array#uniq" do 132 | user2 = Foo::User.find(1) 133 | expect([user, user2].uniq).to eq([user]) 134 | end 135 | 136 | it "treats equal resources as equal for hash keys" do 137 | Foo::User.find(1) 138 | hash = { user => true } 139 | hash[Foo::User.find(1)] = false 140 | expect(hash.size).to eq(1) 141 | expect(hash).to eq(user => false) 142 | end 143 | end 144 | 145 | context "handling metadata and errors" do 146 | before do 147 | Her::API.setup url: "https://api.example.com" do |builder| 148 | builder.use Her::Middleware::FirstLevelParseJSON 149 | builder.adapter :test do |stub| 150 | stub.post("/users") { [200, {}, { id: 1, fullname: "Tobias Fünke" }.to_json] } 151 | end 152 | end 153 | 154 | spawn_model "Foo::User" do 155 | store_response_errors :errors 156 | store_metadata :my_data 157 | end 158 | 159 | @user = Foo::User.new(_errors: %w(Foo Bar), _metadata: { secret: true }) 160 | end 161 | 162 | it "should return response_errors stored in the method provided by `store_response_errors`" do 163 | expect(@user.errors).to eq(%w(Foo Bar)) 164 | end 165 | 166 | it "should remove the default method for errors" do 167 | expect { @user.response_errors }.to raise_error(NoMethodError) 168 | end 169 | 170 | it "should return metadata stored in the method provided by `store_metadata`" do 171 | expect(@user.my_data).to eq(secret: true) 172 | end 173 | 174 | it "should remove the default method for metadata" do 175 | expect { @user.metadata }.to raise_error(NoMethodError) 176 | end 177 | 178 | it "should work with #save" do 179 | @user.assign_attributes(fullname: "Tobias Fünke") 180 | @user.save 181 | expect { @user.metadata }.to raise_error(NoMethodError) 182 | expect(@user.my_data).to be_empty 183 | expect(@user.errors).to be_empty 184 | end 185 | end 186 | 187 | context "overwriting default attribute methods" do 188 | context "for getter method" do 189 | before do 190 | Her::API.setup url: "https://api.example.com" do |builder| 191 | builder.use Her::Middleware::FirstLevelParseJSON 192 | builder.adapter :test do |stub| 193 | stub.get("/users/1") { [200, {}, { id: 1, fullname: "Tobias Fünke", document: { url: "http://example.com" } }.to_json] } 194 | end 195 | end 196 | 197 | spawn_model "Foo::User" do 198 | def document 199 | @attributes[:document][:url] 200 | end 201 | end 202 | end 203 | 204 | it "bypasses Her's method" do 205 | @user = Foo::User.find(1) 206 | expect(@user.document).to eq("http://example.com") 207 | 208 | @user = Foo::User.find(1) 209 | expect(@user.document).to eq("http://example.com") 210 | end 211 | end 212 | 213 | context "for setter method" do 214 | before do 215 | Her::API.setup url: "https://api.example.com" do |builder| 216 | builder.use Her::Middleware::FirstLevelParseJSON 217 | builder.adapter :test do |stub| 218 | stub.get("/users/1") { [200, {}, { id: 1, fullname: "Tobias Fünke", document: { url: "http://example.com" } }.to_json] } 219 | end 220 | end 221 | 222 | spawn_model "Foo::User" do 223 | def document=(document) 224 | @attributes[:document] = document[:url] 225 | end 226 | end 227 | end 228 | 229 | it "bypasses Her's method" do 230 | @user = Foo::User.find(1) 231 | expect(@user.document).to eq("http://example.com") 232 | 233 | @user = Foo::User.find(1) 234 | expect(@user.document).to eq("http://example.com") 235 | end 236 | end 237 | 238 | context "for predicate method" do 239 | before do 240 | Her::API.setup url: "https://api.example.com" do |builder| 241 | builder.use Her::Middleware::FirstLevelParseJSON 242 | builder.adapter :test do |stub| 243 | stub.get("/users/1") { [200, {}, { id: 1, fullname: "Lindsay Fünke", document: { url: nil } }.to_json] } 244 | stub.get("/users/2") { [200, {}, { id: 1, fullname: "Tobias Fünke", document: { url: "http://example.com" } }.to_json] } 245 | end 246 | end 247 | 248 | spawn_model "Foo::User" do 249 | def document? 250 | document[:url].present? 251 | end 252 | end 253 | end 254 | 255 | it "byoasses Her's method" do 256 | @user = Foo::User.find(1) 257 | expect(@user.document?).to be_falsey 258 | 259 | @user = Foo::User.find(1) 260 | expect(@user.document?).to be_falsey 261 | 262 | @user = Foo::User.find(2) 263 | expect(@user.document?).to be_truthy 264 | end 265 | end 266 | end 267 | 268 | context "attributes class method" do 269 | before do 270 | spawn_model "Foo::User" do 271 | attributes :fullname, :document 272 | end 273 | end 274 | 275 | context "instance" do 276 | subject { Foo::User.new } 277 | 278 | it { is_expected.to respond_to(:fullname) } 279 | it { is_expected.to respond_to(:fullname=) } 280 | it { is_expected.to respond_to(:fullname?) } 281 | end 282 | 283 | it "defines setter that affects @attributes" do 284 | user = Foo::User.new 285 | user.fullname = "Tobias Fünke" 286 | expect(user.attributes[:fullname]).to eq("Tobias Fünke") 287 | end 288 | 289 | it "defines getter that reads @attributes" do 290 | user = Foo::User.new 291 | user.assign_attributes(fullname: "Tobias Fünke") 292 | expect(user.fullname).to eq("Tobias Fünke") 293 | end 294 | 295 | it "defines predicate that reads @attributes" do 296 | user = Foo::User.new 297 | expect(user.fullname?).to be_falsey 298 | user.assign_attributes(fullname: "Tobias Fünke") 299 | expect(user.fullname?).to be_truthy 300 | end 301 | 302 | context "when attribute methods are already defined" do 303 | before do 304 | class AbstractUser 305 | attr_accessor :fullname 306 | 307 | def fullname? 308 | @fullname.present? 309 | end 310 | end 311 | @spawned_models << :AbstractUser 312 | 313 | spawn_model "Foo::User", super_class: AbstractUser do 314 | attributes :fullname 315 | end 316 | end 317 | 318 | it "overrides getter method" do 319 | expect(Foo::User.generated_attribute_methods.instance_methods).to include(:fullname) 320 | end 321 | 322 | it "overrides setter method" do 323 | expect(Foo::User.generated_attribute_methods.instance_methods).to include(:fullname=) 324 | end 325 | 326 | it "overrides predicate method" do 327 | expect(Foo::User.generated_attribute_methods.instance_methods).to include(:fullname?) 328 | end 329 | 330 | it "defines setter that affects @attributes" do 331 | user = Foo::User.new 332 | user.fullname = "Tobias Fünke" 333 | expect(user.attributes[:fullname]).to eq("Tobias Fünke") 334 | end 335 | 336 | it "defines getter that reads @attributes" do 337 | user = Foo::User.new 338 | user.attributes[:fullname] = "Tobias Fünke" 339 | expect(user.fullname).to eq("Tobias Fünke") 340 | end 341 | 342 | it "defines predicate that reads @attributes" do 343 | user = Foo::User.new 344 | expect(user.fullname?).to be_falsey 345 | user.attributes[:fullname] = "Tobias Fünke" 346 | expect(user.fullname?).to be_truthy 347 | end 348 | end 349 | 350 | if ActiveModel::VERSION::MAJOR < 4 351 | it "creates a new mutex" do 352 | expect(Mutex).to receive(:new).once.and_call_original 353 | spawn_model "Foo::User" do 354 | attributes :fullname 355 | end 356 | expect(Foo::User.attribute_methods_mutex).not_to eq(Foo::User.generated_attribute_methods) 357 | end 358 | 359 | it "works well with Module#synchronize monkey patched by ActiveSupport" do 360 | Module.class_eval do 361 | def synchronize(*_args) 362 | raise "gotcha!" 363 | end 364 | end 365 | expect(Mutex).to receive(:new).once.and_call_original 366 | spawn_model "Foo::User" do 367 | attributes :fullname 368 | end 369 | expect(Foo::User.attribute_methods_mutex).not_to eq(Foo::User.generated_attribute_methods) 370 | Module.class_eval do 371 | undef :synchronize 372 | end 373 | end 374 | else 375 | it "uses ActiveModel's mutex" do 376 | expect(Foo::User.attribute_methods_mutex).to eq(Foo::User.generated_attribute_methods) 377 | end 378 | end 379 | 380 | it "uses a mutex" do 381 | spawn_model "Foo::User" 382 | expect(Foo::User.attribute_methods_mutex).to receive(:synchronize).once.and_call_original 383 | Foo::User.class_eval do 384 | attributes :fullname, :documents 385 | end 386 | end 387 | end 388 | end 389 | -------------------------------------------------------------------------------- /spec/model/paths_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.join(File.dirname(__FILE__), "../spec_helper.rb") 3 | 4 | describe Her::Model::Paths do 5 | context "building request paths" do 6 | context "simple model" do 7 | before do 8 | spawn_model "Foo::User" 9 | end 10 | 11 | describe "#request_path" do 12 | it "builds paths with defaults" do 13 | expect(Foo::User.new(id: "foo").request_path).to eq("users/foo") 14 | expect(Foo::User.new(id: nil).request_path).to eq("users") 15 | expect(Foo::User.new.request_path).to eq("users") 16 | end 17 | 18 | it "builds paths with custom collection path" do 19 | Foo::User.collection_path "/utilisateurs" 20 | expect(Foo::User.new(id: "foo").request_path).to eq("/utilisateurs/foo") 21 | expect(Foo::User.new.request_path).to eq("/utilisateurs") 22 | end 23 | 24 | it "builds paths with custom relative collection path" do 25 | Foo::User.collection_path "utilisateurs" 26 | expect(Foo::User.new(id: "foo").request_path).to eq("utilisateurs/foo") 27 | expect(Foo::User.new.request_path).to eq("utilisateurs") 28 | end 29 | 30 | it "builds paths with custom collection path with multiple variables" do 31 | Foo::User.collection_path "/organizations/:organization_id/utilisateurs" 32 | 33 | expect(Foo::User.new(id: "foo").request_path(_organization_id: "acme")).to eq("/organizations/acme/utilisateurs/foo") 34 | expect(Foo::User.new.request_path(_organization_id: "acme")).to eq("/organizations/acme/utilisateurs") 35 | 36 | expect(Foo::User.new(id: "foo", organization_id: "acme").request_path).to eq("/organizations/acme/utilisateurs/foo") 37 | expect(Foo::User.new(organization_id: "acme").request_path).to eq("/organizations/acme/utilisateurs") 38 | end 39 | 40 | it "builds paths with custom relative collection path with multiple variables" do 41 | Foo::User.collection_path "organizations/:organization_id/utilisateurs" 42 | 43 | expect(Foo::User.new(id: "foo").request_path(_organization_id: "acme")).to eq("organizations/acme/utilisateurs/foo") 44 | expect(Foo::User.new.request_path(_organization_id: "acme")).to eq("organizations/acme/utilisateurs") 45 | 46 | expect(Foo::User.new(id: "foo", organization_id: "acme").request_path).to eq("organizations/acme/utilisateurs/foo") 47 | expect(Foo::User.new(organization_id: "acme").request_path).to eq("organizations/acme/utilisateurs") 48 | end 49 | 50 | it "builds paths with custom item path" do 51 | Foo::User.resource_path "/utilisateurs/:id" 52 | expect(Foo::User.new(id: "foo").request_path).to eq("/utilisateurs/foo") 53 | expect(Foo::User.new.request_path).to eq("users") 54 | end 55 | 56 | it "builds paths with custom relative item path" do 57 | Foo::User.resource_path "utilisateurs/:id" 58 | expect(Foo::User.new(id: "foo").request_path).to eq("utilisateurs/foo") 59 | expect(Foo::User.new.request_path).to eq("users") 60 | end 61 | 62 | it "raises exceptions when building a path without required custom variables" do 63 | Foo::User.collection_path "/organizations/:organization_id/utilisateurs" 64 | expect { Foo::User.new(id: "foo").request_path }.to raise_error(Her::Errors::PathError, "Missing :_organization_id parameter to build the request path. Path is `/organizations/:organization_id/utilisateurs/:id`. Parameters are `{:id=>\"foo\"}`.") 65 | end 66 | 67 | it "escapes the variable values" do 68 | Foo::User.collection_path "organizations/:organization_id/utilisateurs" 69 | expect(Foo::User.new(id: "Привет").request_path(_organization_id: "лол")).to eq("organizations/%D0%BB%D0%BE%D0%BB/utilisateurs/%D0%9F%D1%80%D0%B8%D0%B2%D0%B5%D1%82") 70 | expect(Foo::User.new(organization_id: "лол", id: "Привет").request_path).to eq("organizations/%D0%BB%D0%BE%D0%BB/utilisateurs/%D0%9F%D1%80%D0%B8%D0%B2%D0%B5%D1%82") 71 | end 72 | end 73 | end 74 | 75 | context "simple model with multiple words" do 76 | before do 77 | spawn_model "Foo::AdminUser" 78 | end 79 | 80 | describe "#request_path" do 81 | it "builds paths with defaults" do 82 | expect(Foo::AdminUser.new(id: "foo").request_path).to eq("admin_users/foo") 83 | expect(Foo::AdminUser.new.request_path).to eq("admin_users") 84 | end 85 | 86 | it "builds paths with custom collection path" do 87 | Foo::AdminUser.collection_path "/users" 88 | expect(Foo::AdminUser.new(id: "foo").request_path).to eq("/users/foo") 89 | expect(Foo::AdminUser.new.request_path).to eq("/users") 90 | end 91 | 92 | it "builds paths with custom relative collection path" do 93 | Foo::AdminUser.collection_path "users" 94 | expect(Foo::AdminUser.new(id: "foo").request_path).to eq("users/foo") 95 | expect(Foo::AdminUser.new.request_path).to eq("users") 96 | end 97 | 98 | it "builds paths with custom collection path with multiple variables" do 99 | Foo::AdminUser.collection_path "/organizations/:organization_id/users" 100 | expect(Foo::AdminUser.new(id: "foo").request_path(_organization_id: "acme")).to eq("/organizations/acme/users/foo") 101 | expect(Foo::AdminUser.new.request_path(_organization_id: "acme")).to eq("/organizations/acme/users") 102 | end 103 | 104 | it "builds paths with custom relative collection path with multiple variables" do 105 | Foo::AdminUser.collection_path "organizations/:organization_id/users" 106 | expect(Foo::AdminUser.new(id: "foo").request_path(_organization_id: "acme")).to eq("organizations/acme/users/foo") 107 | expect(Foo::AdminUser.new.request_path(_organization_id: "acme")).to eq("organizations/acme/users") 108 | end 109 | 110 | it "builds paths with custom item path" do 111 | Foo::AdminUser.resource_path "/users/:id" 112 | expect(Foo::AdminUser.new(id: "foo").request_path).to eq("/users/foo") 113 | expect(Foo::AdminUser.new.request_path).to eq("admin_users") 114 | end 115 | 116 | it "builds paths with custom relative item path" do 117 | Foo::AdminUser.resource_path "users/:id" 118 | expect(Foo::AdminUser.new(id: "foo").request_path).to eq("users/foo") 119 | expect(Foo::AdminUser.new.request_path).to eq("admin_users") 120 | end 121 | 122 | it "raises exceptions when building a path without required custom variables" do 123 | Foo::AdminUser.collection_path "/organizations/:organization_id/users" 124 | expect { Foo::AdminUser.new(id: "foo").request_path }.to raise_error(Her::Errors::PathError, "Missing :_organization_id parameter to build the request path. Path is `/organizations/:organization_id/users/:id`. Parameters are `{:id=>\"foo\"}`.") 125 | end 126 | 127 | it "raises exceptions when building a relative path without required custom variables" do 128 | Foo::AdminUser.collection_path "organizations/:organization_id/users" 129 | expect { Foo::AdminUser.new(id: "foo").request_path }.to raise_error(Her::Errors::PathError, "Missing :_organization_id parameter to build the request path. Path is `organizations/:organization_id/users/:id`. Parameters are `{:id=>\"foo\"}`.") 130 | end 131 | end 132 | end 133 | 134 | context "children model" do 135 | before do 136 | Her::API.setup url: "https://api.example.com" do |builder| 137 | builder.use Her::Middleware::FirstLevelParseJSON 138 | builder.use Faraday::Request::UrlEncoded 139 | builder.adapter :test do |stub| 140 | stub.get("/users/foo") { [200, {}, { id: "foo" }.to_json] } 141 | end 142 | end 143 | 144 | spawn_model("Foo::Model") { include_root_in_json true } 145 | 146 | class User < Foo::Model; end 147 | @spawned_models << :User 148 | end 149 | 150 | it "builds path using the children model name" do 151 | expect(User.find("foo").id).to eq("foo") 152 | expect(User.find("foo").id).to eq("foo") 153 | end 154 | end 155 | 156 | context "nested model" do 157 | before do 158 | spawn_model "Foo::User" 159 | end 160 | 161 | describe "#request_path" do 162 | it "builds paths with defaults" do 163 | expect(Foo::User.new(id: "foo").request_path).to eq("users/foo") 164 | expect(Foo::User.new.request_path).to eq("users") 165 | end 166 | end 167 | end 168 | 169 | context "custom primary key" do 170 | before do 171 | spawn_model "User" do 172 | primary_key "UserId" 173 | resource_path "users/:UserId" 174 | end 175 | 176 | spawn_model "Customer" do 177 | primary_key :customer_id 178 | resource_path "customers/:id" 179 | end 180 | end 181 | 182 | describe "#request_path" do 183 | it "uses the correct primary key attribute" do 184 | expect(User.new(UserId: "foo").request_path).to eq("users/foo") 185 | expect(User.new(id: "foo").request_path).to eq("users") 186 | end 187 | 188 | it "replaces :id with the appropriate primary key" do 189 | expect(Customer.new(customer_id: "joe").request_path).to eq("customers/joe") 190 | expect(Customer.new(id: "joe").request_path).to eq("customers") 191 | end 192 | end 193 | end 194 | end 195 | 196 | context "making subdomain HTTP requests" do 197 | before do 198 | Her::API.setup url: "https://api.example.com/" do |builder| 199 | builder.use Her::Middleware::FirstLevelParseJSON 200 | builder.use Faraday::Request::UrlEncoded 201 | builder.adapter :test do |stub| 202 | stub.get("organizations/2/users") { [200, {}, [{ id: 1, fullname: "Tobias Fünke", organization_id: 2 }, { id: 2, fullname: "Lindsay Fünke", organization_id: 2 }].to_json] } 203 | stub.post("organizations/2/users") { [200, {}, { id: 1, fullname: "Tobias Fünke", organization_id: 2 }.to_json] } 204 | stub.put("organizations/2/users/1") { [200, {}, { id: 1, fullname: "Lindsay Fünke", organization_id: 2 }.to_json] } 205 | stub.get("organizations/2/users/1") { [200, {}, { id: 1, fullname: "Tobias Fünke", organization_id: 2, active: true }.to_json] } 206 | stub.delete("organizations/2/users/1") { [200, {}, { id: 1, fullname: "Lindsay Fünke", organization_id: 2, active: false }.to_json] } 207 | end 208 | end 209 | 210 | spawn_model "Foo::User" do 211 | collection_path "organizations/:organization_id/users" 212 | end 213 | end 214 | 215 | describe "fetching a resource" do 216 | it "maps a single resource to a Ruby object" do 217 | @user = Foo::User.find(1, _organization_id: 2) 218 | expect(@user.id).to eq(1) 219 | expect(@user.fullname).to eq("Tobias Fünke") 220 | end 221 | 222 | it "maps a single resource using a scope to a Ruby object" do 223 | Foo::User.scope :for_organization, ->(o) { where(organization_id: o) } 224 | @user = Foo::User.for_organization(2).find(1) 225 | expect(@user.id).to eq(1) 226 | expect(@user.fullname).to eq("Tobias Fünke") 227 | end 228 | end 229 | 230 | describe "fetching a collection" do 231 | it "maps a collection of resources to an array of Ruby objects" do 232 | @users = Foo::User.where(_organization_id: 2).all 233 | expect(@users.length).to eq(2) 234 | expect(@users.first.fullname).to eq("Tobias Fünke") 235 | end 236 | end 237 | 238 | describe "handling new resource" do 239 | it "handles new resource" do 240 | @new_user = Foo::User.new(fullname: "Tobias Fünke", organization_id: 2) 241 | expect(@new_user.new?).to be_truthy 242 | 243 | @existing_user = Foo::User.find(1, _organization_id: 2) 244 | expect(@existing_user.new?).to be_falsey 245 | end 246 | end 247 | 248 | describe "creating resources" do 249 | it "handle one-line resource creation" do 250 | @user = Foo::User.create(fullname: "Tobias Fünke", organization_id: 2) 251 | expect(@user.id).to eq(1) 252 | expect(@user.fullname).to eq("Tobias Fünke") 253 | end 254 | 255 | it "handle resource creation through Model.new + #save" do 256 | @user = Foo::User.new(fullname: "Tobias Fünke", organization_id: 2) 257 | @user.save 258 | expect(@user.fullname).to eq("Tobias Fünke") 259 | end 260 | end 261 | 262 | context "updating resources" do 263 | it "handle resource data update without saving it" do 264 | @user = Foo::User.find(1, _organization_id: 2) 265 | expect(@user.fullname).to eq("Tobias Fünke") 266 | @user.fullname = "Kittie Sanchez" 267 | expect(@user.fullname).to eq("Kittie Sanchez") 268 | end 269 | 270 | it "handle resource update through the .update class method" do 271 | @user = Foo::User.save_existing(1, fullname: "Lindsay Fünke", organization_id: 2) 272 | expect(@user.fullname).to eq("Lindsay Fünke") 273 | end 274 | 275 | it "handle resource update through #save on an existing resource" do 276 | @user = Foo::User.find(1, _organization_id: 2) 277 | @user.fullname = "Lindsay Fünke" 278 | @user.save 279 | expect(@user.fullname).to eq("Lindsay Fünke") 280 | end 281 | end 282 | 283 | context "deleting resources" do 284 | it "handle resource deletion through the .destroy class method" do 285 | @user = Foo::User.destroy_existing(1, _organization_id: 2) 286 | expect(@user.active).to be_falsey 287 | end 288 | 289 | it "handle resource deletion through #destroy on an existing resource" do 290 | @user = Foo::User.find(1, _organization_id: 2) 291 | @user.destroy 292 | expect(@user.active).to be_falsey 293 | end 294 | end 295 | end 296 | 297 | context "making path HTTP requests" do 298 | before do 299 | Her::API.setup url: "https://example.com/api/" do |builder| 300 | builder.use Her::Middleware::FirstLevelParseJSON 301 | builder.use Faraday::Request::UrlEncoded 302 | builder.adapter :test do |stub| 303 | stub.get("/api/organizations/2/users") { [200, {}, [{ id: 1, fullname: "Tobias Fünke", organization_id: 2 }, { id: 2, fullname: "Lindsay Fünke", organization_id: 2 }].to_json] } 304 | stub.get("/api/organizations/2/users/1") { [200, {}, { id: 1, fullname: "Tobias Fünke", organization_id: 2, active: true }.to_json] } 305 | end 306 | end 307 | 308 | spawn_model "Foo::User" do 309 | collection_path "organizations/:organization_id/users" 310 | end 311 | end 312 | 313 | describe "fetching a resource" do 314 | it "maps a single resource to a Ruby object" do 315 | @user = Foo::User.find(1, _organization_id: 2) 316 | expect(@user.id).to eq(1) 317 | expect(@user.fullname).to eq("Tobias Fünke") 318 | end 319 | end 320 | 321 | describe "fetching a collection" do 322 | it "maps a collection of resources to an array of Ruby objects" do 323 | @users = Foo::User.where(_organization_id: 2).all 324 | expect(@users.length).to eq(2) 325 | expect(@users.first.fullname).to eq("Tobias Fünke") 326 | end 327 | end 328 | 329 | describe "fetching a resource with absolute path" do 330 | it "maps a single resource to a Ruby object" do 331 | Foo::User.resource_path "/api/" + Foo::User.resource_path 332 | @user = Foo::User.find(1, _organization_id: 2) 333 | expect(@user.id).to eq(1) 334 | expect(@user.fullname).to eq("Tobias Fünke") 335 | end 336 | end 337 | 338 | describe "fetching a collection with absolute path" do 339 | it "maps a collection of resources to an array of Ruby objects" do 340 | Foo::User.collection_path "/api/" + Foo::User.collection_path 341 | @users = Foo::User.where(_organization_id: 2).all 342 | expect(@users.length).to eq(2) 343 | expect(@users.first.fullname).to eq("Tobias Fünke") 344 | end 345 | end 346 | end 347 | end 348 | -------------------------------------------------------------------------------- /spec/model/orm_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.join(File.dirname(__FILE__), "../spec_helper.rb") 3 | 4 | describe Her::Model::ORM do 5 | context "mapping data to Ruby objects" do 6 | before do 7 | api = Her::API.new 8 | api.setup url: "https://api.example.com" do |builder| 9 | builder.use Her::Middleware::FirstLevelParseJSON 10 | builder.use Faraday::Request::UrlEncoded 11 | builder.adapter :test do |stub| 12 | stub.get("/users/1") { [200, {}, { id: 1, name: "Tobias Fünke" }.to_json] } 13 | stub.get("/users") { [200, {}, [{ id: 1, name: "Tobias Fünke" }, { id: 2, name: "Lindsay Fünke" }].to_json] } 14 | stub.get("/admin_users") { [200, {}, [{ admin_id: 1, name: "Tobias Fünke" }, { admin_id: 2, name: "Lindsay Fünke" }].to_json] } 15 | stub.get("/admin_users/1") { [200, {}, { admin_id: 1, name: "Tobias Fünke" }.to_json] } 16 | end 17 | end 18 | 19 | spawn_model "Foo::User" do 20 | uses_api api 21 | end 22 | 23 | spawn_model "Foo::AdminUser" do 24 | uses_api api 25 | primary_key :admin_id 26 | end 27 | end 28 | 29 | it "maps a single resource to a Ruby object" do 30 | @user = Foo::User.find(1) 31 | expect(@user.id).to eq(1) 32 | expect(@user.name).to eq("Tobias Fünke") 33 | 34 | @admin = Foo::AdminUser.find(1) 35 | expect(@admin.id).to eq(1) 36 | expect(@admin.name).to eq("Tobias Fünke") 37 | end 38 | 39 | it "maps a collection of resources to an array of Ruby objects" do 40 | @users = Foo::User.all 41 | expect(@users.length).to eq(2) 42 | expect(@users.first.name).to eq("Tobias Fünke") 43 | 44 | @users = Foo::AdminUser.all 45 | expect(@users.length).to eq(2) 46 | expect(@users.first.name).to eq("Tobias Fünke") 47 | end 48 | 49 | it "handles new resource" do 50 | @new_user = Foo::User.new(fullname: "Tobias Fünke") 51 | expect(@new_user.new?).to be_truthy 52 | expect(@new_user.new_record?).to be_truthy 53 | expect(@new_user.fullname).to eq("Tobias Fünke") 54 | 55 | @existing_user = Foo::User.find(1) 56 | expect(@existing_user.new?).to be_falsey 57 | expect(@existing_user.new_record?).to be_falsey 58 | end 59 | 60 | it "handles new resource with custom primary key" do 61 | @new_user = Foo::AdminUser.new(fullname: "Lindsay Fünke", id: -1) 62 | expect(@new_user).to be_new 63 | 64 | @existing_user = Foo::AdminUser.find(1) 65 | expect(@existing_user).not_to be_new 66 | end 67 | end 68 | 69 | context "mapping data, metadata and error data to Ruby objects" do 70 | before do 71 | api = Her::API.new 72 | api.setup url: "https://api.example.com" do |builder| 73 | builder.use Her::Middleware::SecondLevelParseJSON 74 | builder.use Faraday::Request::UrlEncoded 75 | builder.adapter :test do |stub| 76 | stub.get("/users") { [200, {}, { data: [{ id: 1, name: "Tobias Fünke" }, { id: 2, name: "Lindsay Fünke" }], metadata: { total_pages: 10, next_page: 2 }, errors: %w(Oh My God) }.to_json] } 77 | stub.get("/users") { |env| [200, {}, { :data => [{ :id => 1, :name => "Tobias Fünke" }, { :id => 2, :name => "Lindsay Fünke" }], :metadata => { :total_pages => 10, :next_page => 2 }, :errors => ["Oh", "My", "God"] }.to_json] } 78 | stub.post("/users") { |env| [200, {}, { :data => { :name => "George Michael Bluth" }, :metadata => { :foo => "bar" }, :errors => ["Yes", "Sir"] }.to_json] } 79 | stub.delete("/users/1") { |env| [200, {}, { :data => { :id => 1 }, :metadata => { :foo => "bar" }, :errors => ["Yes", "Sir"] }.to_json] } 80 | end 81 | end 82 | 83 | spawn_model :User do 84 | uses_api api 85 | end 86 | end 87 | 88 | it "handles metadata on a collection" do 89 | @users = User.all 90 | expect(@users.metadata[:total_pages]).to eq(10) 91 | end 92 | 93 | it "handles error data on a collection" do 94 | @users = User.all 95 | expect(@users.errors.length).to eq(3) 96 | end 97 | 98 | it "handles metadata on a resource" do 99 | @user = User.create(name: "George Michael Bluth") 100 | expect(@user.metadata[:foo]).to eq("bar") 101 | end 102 | 103 | it "handles error data on a resource" do 104 | @user = User.create(name: "George Michael Bluth") 105 | expect(@user.response_errors).to eq(%w(Yes Sir)) 106 | end 107 | 108 | it "handles metadata on a destroyed resource" do 109 | @user = User.destroy_existing(1) 110 | expect(@user.metadata[:foo]).to eq("bar") 111 | end 112 | 113 | it "handles error data on a destroyed resource" do 114 | @user = User.destroy_existing(1) 115 | expect(@user.response_errors).to eq(%w(Yes Sir)) 116 | end 117 | end 118 | 119 | context "mapping data, metadata and error data in string keys to Ruby objects" do 120 | before do 121 | api = Her::API.new 122 | api.setup url: "https://api.example.com" do |builder| 123 | builder.use Her::Middleware::SecondLevelParseJSON 124 | builder.use Faraday::Request::UrlEncoded 125 | builder.adapter :test do |stub| 126 | stub.get("/users") { [200, {}, { data: [{ id: 1, name: "Tobias Fünke" }, { id: 2, name: "Lindsay Fünke" }], metadata: { total_pages: 10, next_page: 2 }, errors: %w(Oh My God) }.to_json] } 127 | stub.post("/users") { [200, {}, { data: { name: "George Michael Bluth" }, metadata: { foo: "bar" }, errors: %w(Yes Sir) }.to_json] } 128 | end 129 | end 130 | 131 | spawn_model :User do 132 | uses_api api 133 | end 134 | end 135 | 136 | it "handles metadata on a collection" do 137 | @users = User.all 138 | expect(@users.metadata[:total_pages]).to eq(10) 139 | end 140 | 141 | it "handles error data on a collection" do 142 | @users = User.all 143 | expect(@users.errors.length).to eq(3) 144 | end 145 | 146 | it "handles metadata on a resource" do 147 | @user = User.create(name: "George Michael Bluth") 148 | expect(@user.metadata[:foo]).to eq("bar") 149 | end 150 | 151 | it "handles error data on a resource" do 152 | @user = User.create(name: "George Michael Bluth") 153 | expect(@user.response_errors).to eq(%w(Yes Sir)) 154 | end 155 | end 156 | 157 | context "defining custom getters and setters" do 158 | before do 159 | api = Her::API.new 160 | api.setup url: "https://api.example.com" do |builder| 161 | builder.use Her::Middleware::FirstLevelParseJSON 162 | builder.use Faraday::Request::UrlEncoded 163 | builder.adapter :test do |stub| 164 | stub.get("/users/1") { [200, {}, { id: 1, friends: %w(Maeby GOB Anne) }.to_json] } 165 | stub.get("/users/2") { [200, {}, { id: 1 }.to_json] } 166 | end 167 | end 168 | 169 | spawn_model :User do 170 | uses_api api 171 | belongs_to :organization 172 | 173 | def friends=(val) 174 | val = val.delete("\r").split("\n").map { |friend| friend.gsub(/^\s*\*\s*/, "") } if val && val.is_a?(String) 175 | @attributes[:friends] = val 176 | end 177 | 178 | def friends 179 | @attributes[:friends].map { |friend| "* #{friend}" }.join("\n") 180 | end 181 | end 182 | end 183 | 184 | it "handles custom setters" do 185 | @user = User.find(1) 186 | expect(@user.friends).to eq("* Maeby\n* GOB\n* Anne") 187 | @user.instance_eval do 188 | @attributes[:friends] = %w(Maeby GOB Anne) 189 | end 190 | end 191 | 192 | it "handles custom getters" do 193 | @user = User.new 194 | @user.friends = "* George\n* Oscar\n* Lucille" 195 | expect(@user.friends).to eq("* George\n* Oscar\n* Lucille") 196 | @user.instance_eval do 197 | @attributes[:friends] = %w(George Oscar Lucille) 198 | end 199 | end 200 | end 201 | 202 | context "finding resources" do 203 | before do 204 | api = Her::API.new 205 | api.setup url: "https://api.example.com" do |builder| 206 | builder.use Her::Middleware::FirstLevelParseJSON 207 | builder.use Faraday::Request::UrlEncoded 208 | builder.adapter :test do |stub| 209 | stub.get("/users/1") { [200, {}, { id: 1, age: 42 }.to_json] } 210 | stub.get("/users/2") { [200, {}, { id: 2, age: 34 }.to_json] } 211 | stub.get("/users?id[]=1&id[]=2") { [200, {}, [{ id: 1, age: 42 }, { id: 2, age: 34 }].to_json] } 212 | stub.get("/users?age=42&foo=bar") { [200, {}, [{ id: 3, age: 42 }].to_json] } 213 | stub.get("/users?age=42") { [200, {}, [{ id: 1, age: 42 }].to_json] } 214 | stub.get("/users?age=40") { [200, {}, [{ id: 1, age: 40 }].to_json] } 215 | stub.get("/users?name=baz") { [200, {}, [].to_json] } 216 | stub.post("/users") { [200, {}, { id: 5, name: "baz" }.to_json] } 217 | end 218 | end 219 | 220 | spawn_model :User do 221 | uses_api api 222 | end 223 | end 224 | 225 | it "handles finding by a single id" do 226 | @user = User.find(1) 227 | expect(@user.id).to eq(1) 228 | end 229 | 230 | it "handles finding by multiple ids" do 231 | @users = User.find(1, 2) 232 | expect(@users).to be_kind_of(Array) 233 | expect(@users.length).to eq(2) 234 | expect(@users[0].id).to eq(1) 235 | expect(@users[1].id).to eq(2) 236 | end 237 | 238 | it "handles finding by an array of ids" do 239 | @users = User.find([1, 2]) 240 | expect(@users).to be_kind_of(Array) 241 | expect(@users.length).to eq(2) 242 | expect(@users[0].id).to eq(1) 243 | expect(@users[1].id).to eq(2) 244 | end 245 | 246 | it "handles finding by an array of ids of length 1" do 247 | @users = User.find([1]) 248 | expect(@users).to be_kind_of(Array) 249 | expect(@users.length).to eq(1) 250 | expect(@users[0].id).to eq(1) 251 | end 252 | 253 | it "handles finding by an array id param of length 2" do 254 | @users = User.find(id: [1, 2]) 255 | expect(@users).to be_kind_of(Array) 256 | expect(@users.length).to eq(2) 257 | expect(@users[0].id).to eq(1) 258 | expect(@users[1].id).to eq(2) 259 | end 260 | 261 | it "handles finding with id parameter as an array" do 262 | @users = User.where(id: [1, 2]) 263 | expect(@users).to be_kind_of(Array) 264 | expect(@users.length).to eq(2) 265 | expect(@users[0].id).to eq(1) 266 | expect(@users[1].id).to eq(2) 267 | end 268 | 269 | it "handles finding by attributes" do 270 | @user = User.find_by(age: 42) 271 | expect(@user).to be_a(User) 272 | expect(@user.id).to eq(1) 273 | end 274 | 275 | it "handles find or create by attributes" do 276 | @user = User.find_or_create_by(name: "baz") 277 | expect(@user).to be_a(User) 278 | expect(@user.id).to eq(5) 279 | end 280 | 281 | it "handles find or initialize by attributes" do 282 | @user = User.find_or_initialize_by(name: "baz") 283 | expect(@user).to be_a(User) 284 | expect(@user).to_not be_persisted 285 | end 286 | 287 | it "handles finding with other parameters" do 288 | @users = User.where(age: 42, foo: "bar").all 289 | expect(@users).to be_kind_of(Array) 290 | expect(@users.first.id).to eq(3) 291 | end 292 | 293 | it "handles finding with other parameters and scoped" do 294 | @users = User.scoped 295 | expect(@users.where(age: 42)).to be_all { |u| u.age == 42 } 296 | expect(@users.where(age: 40)).to be_all { |u| u.age == 40 } 297 | end 298 | 299 | it "handles reloading a resource" do 300 | @user = User.find(1) 301 | @user.age = "Oops" 302 | @user.reload 303 | expect(@user.age).to eq 42 304 | expect(@user).to be_persisted 305 | end 306 | end 307 | 308 | context "building resources" do 309 | context "when request_new_object_on_build is not set (default)" do 310 | before do 311 | spawn_model("Foo::User") 312 | end 313 | 314 | it "builds a new resource without requesting it" do 315 | expect(Foo::User).not_to receive(:request) 316 | @new_user = Foo::User.build(fullname: "Tobias Fünke") 317 | expect(@new_user.new?).to be_truthy 318 | expect(@new_user.fullname).to eq("Tobias Fünke") 319 | end 320 | end 321 | 322 | context "when request_new_object_on_build is set" do 323 | before do 324 | Her::API.setup url: "https://api.example.com" do |builder| 325 | builder.use Her::Middleware::FirstLevelParseJSON 326 | builder.use Faraday::Request::UrlEncoded 327 | builder.adapter :test do |stub| 328 | stub.get("/users/new") { |env| ok! id: nil, fullname: params(env)[:fullname], email: "tobias@bluthcompany.com" } 329 | end 330 | end 331 | 332 | spawn_model("Foo::User") { request_new_object_on_build true } 333 | end 334 | 335 | it "requests a new resource" do 336 | expect(Foo::User).to receive(:request).once.and_call_original 337 | @new_user = Foo::User.build(fullname: "Tobias Fünke") 338 | expect(@new_user.new?).to be_truthy 339 | expect(@new_user.fullname).to eq("Tobias Fünke") 340 | expect(@new_user.email).to eq("tobias@bluthcompany.com") 341 | end 342 | end 343 | end 344 | 345 | context "creating resources" do 346 | before do 347 | Her::API.setup url: "https://api.example.com" do |builder| 348 | builder.use Her::Middleware::FirstLevelParseJSON 349 | builder.use Faraday::Request::UrlEncoded 350 | builder.adapter :test do |stub| 351 | stub.post("/users") { |env| [200, {}, { id: 1, fullname: Faraday::Utils.parse_query(env[:body])["fullname"], email: Faraday::Utils.parse_query(env[:body])["email"] }.to_json] } 352 | stub.post("/companies") { [200, {}, { errors: ["name is required"] }.to_json] } 353 | end 354 | end 355 | 356 | spawn_model "Foo::User" 357 | spawn_model "Foo::Company" 358 | end 359 | 360 | it "handle one-line resource creation" do 361 | @user = Foo::User.create(fullname: "Tobias Fünke", email: "tobias@bluth.com") 362 | expect(@user.id).to eq(1) 363 | expect(@user.fullname).to eq("Tobias Fünke") 364 | expect(@user.email).to eq("tobias@bluth.com") 365 | end 366 | 367 | it "handle resource creation through Model.new + #save" do 368 | @user = Foo::User.new(fullname: "Tobias Fünke") 369 | expect(@user.save).to be_truthy 370 | expect(@user.fullname).to eq("Tobias Fünke") 371 | end 372 | 373 | it "handle resource creation through Model.new + #save!" do 374 | @user = Foo::User.new(fullname: "Tobias Fünke") 375 | expect(@user.save!).to be_truthy 376 | expect(@user.fullname).to eq("Tobias Fünke") 377 | end 378 | 379 | it "returns false when #save gets errors" do 380 | @company = Foo::Company.new 381 | expect(@company.save).to be_falsey 382 | end 383 | 384 | it "raises ResourceInvalid when #save! gets errors" do 385 | @company = Foo::Company.new 386 | expect { @company.save! }.to raise_error Her::Errors::ResourceInvalid, "Remote validation failed: name is required" 387 | end 388 | 389 | it "don't overwrite data if response is empty" do 390 | @company = Foo::Company.new(name: "Company Inc.") 391 | expect(@company.save).to be_falsey 392 | expect(@company.name).to eq("Company Inc.") 393 | end 394 | end 395 | 396 | context "updating resources" do 397 | before do 398 | Her::API.setup url: "https://api.example.com" do |builder| 399 | builder.use Her::Middleware::FirstLevelParseJSON 400 | builder.use Faraday::Request::UrlEncoded 401 | builder.adapter :test do |stub| 402 | stub.get("/users/1") { [200, {}, { id: 1, fullname: "Tobias Fünke", admin: false }.to_json] } 403 | stub.put("/users/1") { [200, {}, { id: 1, fullname: "Lindsay Fünke", admin: true }.to_json] } 404 | stub.get("/pages/1") { [200, {}, { id: 1, views: 1, unique_visitors: 4 }.to_json] } 405 | stub.put("/pages/1") { [200, {}, { id: 1, views: 2, unique_visitors: 3 }.to_json] } 406 | end 407 | end 408 | 409 | spawn_model "Foo::User" 410 | spawn_model "Foo::Page" 411 | end 412 | 413 | it "handle resource data update without saving it" do 414 | @user = Foo::User.find(1) 415 | expect(@user.fullname).to eq("Tobias Fünke") 416 | @user.fullname = "Kittie Sanchez" 417 | expect(@user.fullname).to eq("Kittie Sanchez") 418 | end 419 | 420 | it "handle resource update through the .update class method" do 421 | @user = Foo::User.save_existing(1, fullname: "Lindsay Fünke") 422 | expect(@user.fullname).to eq("Lindsay Fünke") 423 | end 424 | 425 | it "handle resource update through #save on an existing resource" do 426 | @user = Foo::User.find(1) 427 | @user.fullname = "Lindsay Fünke" 428 | @user.save 429 | expect(@user.fullname).to eq("Lindsay Fünke") 430 | end 431 | 432 | it "handles resource update through #toggle without saving it" do 433 | @user = Foo::User.find(1) 434 | expect(@user.admin).to be_falsey 435 | expect(@user).to_not receive(:save) 436 | @user.toggle(:admin) 437 | expect(@user.admin).to be_truthy 438 | end 439 | 440 | it "handles resource update through #toggle!" do 441 | @user = Foo::User.find(1) 442 | expect(@user.admin).to be_falsey 443 | expect(@user).to receive(:save).and_return(true) 444 | @user.toggle!(:admin) 445 | expect(@user.admin).to be_truthy 446 | end 447 | 448 | it "handles resource update through #increment without saving it" do 449 | page = Foo::Page.find(1) 450 | expect(page.views).to be 1 451 | expect(page).to_not receive(:save) 452 | page.increment(:views) 453 | expect(page.views).to be 2 454 | page.increment(:views, 2) 455 | expect(page.views).to be 4 456 | end 457 | 458 | it "handles resource update through #increment!" do 459 | page = Foo::Page.find(1) 460 | expect(page.views).to be 1 461 | expect(page).to receive(:save).and_return(true) 462 | page.increment!(:views) 463 | expect(page.views).to be 2 464 | end 465 | 466 | it "handles resource update through #decrement without saving it" do 467 | page = Foo::Page.find(1) 468 | expect(page.unique_visitors).to be 4 469 | expect(page).to_not receive(:save) 470 | page.decrement(:unique_visitors) 471 | expect(page.unique_visitors).to be 3 472 | page.decrement(:unique_visitors, 2) 473 | expect(page.unique_visitors).to be 1 474 | end 475 | 476 | it "handles resource update through #decrement!" do 477 | page = Foo::Page.find(1) 478 | expect(page.unique_visitors).to be 4 479 | expect(page).to receive(:save).and_return(true) 480 | page.decrement!(:unique_visitors) 481 | expect(page.unique_visitors).to be 3 482 | end 483 | end 484 | 485 | context "deleting resources" do 486 | let(:status) { 200 } 487 | before do 488 | Her::API.setup url: "https://api.example.com" do |builder| 489 | builder.use Her::Middleware::FirstLevelParseJSON 490 | builder.use Faraday::Request::UrlEncoded 491 | builder.adapter :test do |stub| 492 | stub.get("/users/1") { [200, {}, { id: 1, fullname: "Tobias Fünke", active: true }.to_json] } 493 | stub.delete("/users/1") { [status, {}, { id: 1, fullname: "Lindsay Fünke", active: false }.to_json] } 494 | end 495 | end 496 | 497 | spawn_model "Foo::User" 498 | end 499 | 500 | it "handle resource deletion through the .destroy class method" do 501 | @user = Foo::User.destroy_existing(1) 502 | expect(@user.active).to be_falsey 503 | expect(@user).to be_destroyed 504 | end 505 | 506 | it "handle resource deletion through #destroy on an existing resource" do 507 | @user = Foo::User.find(1) 508 | @user.destroy 509 | expect(@user.active).to be_falsey 510 | expect(@user).to be_destroyed 511 | end 512 | 513 | context "with response_errors" do 514 | let(:status) { 422 } 515 | it "set user.destroyed to false if errors are present through the .destroy class method" do 516 | @user = Foo::User.destroy_existing(1) 517 | expect(@user).not_to be_destroyed 518 | end 519 | 520 | it "set user.destroyed to false if errors are present through #destroy on an existing resource" do 521 | @user = Foo::User.find(1) 522 | @user.destroy 523 | expect(@user).not_to be_destroyed 524 | end 525 | end 526 | 527 | context "with params" do 528 | before do 529 | Her::API.setup url: "https://api.example.com" do |builder| 530 | builder.use Her::Middleware::FirstLevelParseJSON 531 | builder.use Faraday::Request::UrlEncoded 532 | builder.adapter :test do |stub| 533 | stub.delete("/users/1?delete_type=soft") { [200, {}, { id: 1, fullname: "Lindsay Fünke", active: false }.to_json] } 534 | end 535 | end 536 | end 537 | 538 | it "handle resource deletion through the .destroy class method" do 539 | @user = Foo::User.destroy_existing(1, delete_type: "soft") 540 | expect(@user.active).to be_falsey 541 | expect(@user).to be_destroyed 542 | end 543 | 544 | it "handle resource deletion through #destroy on an existing resource" do 545 | @user = Foo::User.find(1) 546 | @user.destroy(delete_type: "soft") 547 | expect(@user.active).to be_falsey 548 | expect(@user).to be_destroyed 549 | end 550 | end 551 | end 552 | 553 | context "customizing HTTP methods" do 554 | before do 555 | Her::API.setup url: "https://api.example.com" do |builder| 556 | builder.use Her::Middleware::FirstLevelParseJSON 557 | builder.use Faraday::Request::UrlEncoded 558 | end 559 | end 560 | 561 | context "create" do 562 | before do 563 | Her::API.default_api.connection.adapter :test do |stub| 564 | stub.put("/users") { [200, {}, { id: 1, fullname: "Tobias Fünke" }.to_json] } 565 | end 566 | spawn_model "Foo::User" do 567 | attributes :fullname, :email 568 | method_for :create, "PUT" 569 | end 570 | end 571 | 572 | context "for top-level class" do 573 | it "uses the custom method (PUT) instead of default method (POST)" do 574 | user = Foo::User.new(fullname: "Tobias Fünke") 575 | expect(user).to be_new 576 | expect(user.save).to be_truthy 577 | end 578 | end 579 | 580 | context "for children class" do 581 | before do 582 | class User < Foo::User; end 583 | @spawned_models << :User 584 | end 585 | 586 | it "uses the custom method (PUT) instead of default method (POST)" do 587 | user = User.new(fullname: "Tobias Fünke") 588 | expect(user).to be_new 589 | expect(user.save).to be_truthy 590 | end 591 | end 592 | end 593 | 594 | context "update" do 595 | before do 596 | Her::API.default_api.connection.adapter :test do |stub| 597 | stub.get("/users/1") { [200, {}, { id: 1, fullname: "Lindsay Fünke" }.to_json] } 598 | stub.post("/users/1") { [200, {}, { id: 1, fullname: "Tobias Fünke" }.to_json] } 599 | end 600 | 601 | spawn_model "Foo::User" do 602 | attributes :fullname, :email 603 | method_for :update, :post 604 | end 605 | end 606 | 607 | it "uses the custom method (POST) instead of default method (PUT)" do 608 | user = Foo::User.find(1) 609 | expect(user.fullname).to eq "Lindsay Fünke" 610 | user.fullname = "Toby Fünke" 611 | user.save 612 | expect(user.fullname).to eq "Tobias Fünke" 613 | end 614 | end 615 | end 616 | end 617 | --------------------------------------------------------------------------------