From 7ea759607150a71b6437fc48211872e80d4a7424 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 21:30:20 -0600 Subject: [PATCH 01/31] Modernize build infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Require Ruby >= 3.1, ActiveModel >= 6.1, Faraday >= 2.0 - Replace Travis CI with GitHub Actions (Ruby 3.1/3.2/3.3 × AM 6.1–8.0) - Remove multi_json dependency in favor of stdlib JSON - Drop ancient dev deps (json ~> 1.8, rake ~> 10.0) - Clean up Gemfile Ruby 1.9.3 conditional logic --- .github/workflows/ci.yml | 40 +++++++++++++++++++++++ .rspec | 2 +- .ruby-version | 2 +- .travis.yml | 55 -------------------------------- Gemfile | 8 ----- gemfiles/Gemfile.activemodel-4.2 | 6 ---- gemfiles/Gemfile.activemodel-5.0 | 6 ---- gemfiles/Gemfile.activemodel-5.1 | 6 ---- gemfiles/Gemfile.activemodel-5.2 | 6 ---- gemfiles/Gemfile.activemodel-6.1 | 6 ++++ gemfiles/Gemfile.activemodel-7.0 | 6 ++++ gemfiles/Gemfile.activemodel-7.1 | 6 ++++ gemfiles/Gemfile.activemodel-7.2 | 6 ++++ gemfiles/Gemfile.activemodel-8.0 | 6 ++++ gemfiles/Gemfile.faraday-1.0 | 6 ---- her.gemspec | 14 ++++---- 16 files changed, 79 insertions(+), 102 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .travis.yml delete mode 100644 gemfiles/Gemfile.activemodel-4.2 delete mode 100644 gemfiles/Gemfile.activemodel-5.0 delete mode 100644 gemfiles/Gemfile.activemodel-5.1 delete mode 100644 gemfiles/Gemfile.activemodel-5.2 create mode 100644 gemfiles/Gemfile.activemodel-6.1 create mode 100644 gemfiles/Gemfile.activemodel-7.0 create mode 100644 gemfiles/Gemfile.activemodel-7.1 create mode 100644 gemfiles/Gemfile.activemodel-7.2 create mode 100644 gemfiles/Gemfile.activemodel-8.0 delete mode 100644 gemfiles/Gemfile.faraday-1.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..e8b782a4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + ruby: ["3.1", "3.2", "3.3"] + gemfile: + - gemfiles/Gemfile.activemodel-6.1 + - gemfiles/Gemfile.activemodel-7.0 + - gemfiles/Gemfile.activemodel-7.1 + - gemfiles/Gemfile.activemodel-7.2 + - gemfiles/Gemfile.activemodel-8.0 + exclude: + - ruby: "3.1" + gemfile: gemfiles/Gemfile.activemodel-8.0 + + env: + BUNDLE_GEMFILE: ${{ matrix.gemfile }} + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby ${{ matrix.ruby }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Run specs + run: bundle exec rake spec diff --git a/.rspec b/.rspec index f78983b8..74167f07 100644 --- a/.rspec +++ b/.rspec @@ -1 +1 @@ ---colour --format=documentation +--color --format=documentation diff --git a/.ruby-version b/.ruby-version index 530cdd91..5f6fc5ed 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.2.4 +3.3.10 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b693a557..00000000 --- a/.travis.yml +++ /dev/null @@ -1,55 +0,0 @@ -language: ruby - -sudo: false - -rvm: - - 1.9.3 - - 2.0.0 - - 2.1.6 - - 2.2.10 - - 2.3.7 - - 2.4.3 - - 2.5.1 - - 2.6.6 - -gemfile: - - gemfiles/Gemfile.activemodel-4.2 - - gemfiles/Gemfile.activemodel-5.0 - - gemfiles/Gemfile.activemodel-5.1 - - gemfiles/Gemfile.activemodel-5.2 - - gemfiles/Gemfile.faraday-1.0 - -matrix: - exclude: - - gemfile: gemfiles/Gemfile.activemodel-5.0 - rvm: 1.9.3 - - gemfile: gemfiles/Gemfile.activemodel-5.0 - rvm: 2.0.0 - - gemfile: gemfiles/Gemfile.activemodel-5.0 - rvm: 2.1.6 - - gemfile: gemfiles/Gemfile.activemodel-5.1 - rvm: 1.9.3 - - gemfile: gemfiles/Gemfile.activemodel-5.1 - rvm: 2.0.0 - - gemfile: gemfiles/Gemfile.activemodel-5.1 - rvm: 2.1.6 - - gemfile: gemfiles/Gemfile.activemodel-5.2 - rvm: 1.9.3 - - gemfile: gemfiles/Gemfile.activemodel-5.2 - rvm: 2.0.0 - - gemfile: gemfiles/Gemfile.activemodel-5.2 - rvm: 2.1.6 - - gemfile: gemfiles/Gemfile.faraday-1.0 - rvm: 1.9.3 - - gemfile: gemfiles/Gemfile.faraday-1.0 - rvm: 2.0.0 - - gemfile: gemfiles/Gemfile.faraday-1.0 - rvm: 2.1.6 - - gemfile: gemfiles/Gemfile.faraday-1.0 - rvm: 2.2.10 - -before_install: - - gem uninstall -v '>= 2' -i $(rvm gemdir)@global -ax bundler || true - - gem install bundler -v '< 2' - -script: "echo 'COME ON!' && bundle exec rake spec" diff --git a/Gemfile b/Gemfile index 6892d730..3be9c3cd 100644 --- a/Gemfile +++ b/Gemfile @@ -1,10 +1,2 @@ source "https://rubygems.org" gemspec - -if RbConfig::CONFIG['RUBY_PROGRAM_VERSION'] && RbConfig::CONFIG['RUBY_PROGRAM_VERSION'] >= '1.9.3' - gem 'activemodel', '>= 3.2.0' - gem 'activesupport', '>= 3.2.0' -else - gem 'activemodel', '~> 3.2.0' - gem 'activesupport', '~> 3.2.0' -end diff --git a/gemfiles/Gemfile.activemodel-4.2 b/gemfiles/Gemfile.activemodel-4.2 deleted file mode 100644 index bf7286a3..00000000 --- a/gemfiles/Gemfile.activemodel-4.2 +++ /dev/null @@ -1,6 +0,0 @@ -source "https://rubygems.org" - -gemspec :path => "../" - -gem 'activemodel', '~> 4.2.0' -gem 'faraday', '~> 0.8.9' diff --git a/gemfiles/Gemfile.activemodel-5.0 b/gemfiles/Gemfile.activemodel-5.0 deleted file mode 100644 index 2b646676..00000000 --- a/gemfiles/Gemfile.activemodel-5.0 +++ /dev/null @@ -1,6 +0,0 @@ -source "https://rubygems.org" - -gemspec :path => "../" - -gem 'activemodel', '~> 5.0.0' -gem 'faraday', '~> 0.8.9' diff --git a/gemfiles/Gemfile.activemodel-5.1 b/gemfiles/Gemfile.activemodel-5.1 deleted file mode 100644 index 5b1b26ae..00000000 --- a/gemfiles/Gemfile.activemodel-5.1 +++ /dev/null @@ -1,6 +0,0 @@ -source "https://rubygems.org" - -gemspec :path => "../" - -gem 'activemodel', '~> 5.1.0' -gem 'faraday', '~> 0.8.9' diff --git a/gemfiles/Gemfile.activemodel-5.2 b/gemfiles/Gemfile.activemodel-5.2 deleted file mode 100644 index f8d18985..00000000 --- a/gemfiles/Gemfile.activemodel-5.2 +++ /dev/null @@ -1,6 +0,0 @@ -source "https://rubygems.org" - -gemspec :path => "../" - -gem 'activemodel', '~> 5.2.0' -gem 'faraday', '~> 0.8.9' diff --git a/gemfiles/Gemfile.activemodel-6.1 b/gemfiles/Gemfile.activemodel-6.1 new file mode 100644 index 00000000..7dbef569 --- /dev/null +++ b/gemfiles/Gemfile.activemodel-6.1 @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gemspec path: "../" + +gem "activemodel", "~> 6.1.0" +gem "activesupport", "~> 6.1.0" diff --git a/gemfiles/Gemfile.activemodel-7.0 b/gemfiles/Gemfile.activemodel-7.0 new file mode 100644 index 00000000..021d8df4 --- /dev/null +++ b/gemfiles/Gemfile.activemodel-7.0 @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gemspec path: "../" + +gem "activemodel", "~> 7.0.0" +gem "activesupport", "~> 7.0.0" diff --git a/gemfiles/Gemfile.activemodel-7.1 b/gemfiles/Gemfile.activemodel-7.1 new file mode 100644 index 00000000..522400aa --- /dev/null +++ b/gemfiles/Gemfile.activemodel-7.1 @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gemspec path: "../" + +gem "activemodel", "~> 7.1.0" +gem "activesupport", "~> 7.1.0" diff --git a/gemfiles/Gemfile.activemodel-7.2 b/gemfiles/Gemfile.activemodel-7.2 new file mode 100644 index 00000000..11cfbaed --- /dev/null +++ b/gemfiles/Gemfile.activemodel-7.2 @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gemspec path: "../" + +gem "activemodel", "~> 7.2.0" +gem "activesupport", "~> 7.2.0" diff --git a/gemfiles/Gemfile.activemodel-8.0 b/gemfiles/Gemfile.activemodel-8.0 new file mode 100644 index 00000000..b48a1551 --- /dev/null +++ b/gemfiles/Gemfile.activemodel-8.0 @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gemspec path: "../" + +gem "activemodel", "~> 8.0.0" +gem "activesupport", "~> 8.0.0" diff --git a/gemfiles/Gemfile.faraday-1.0 b/gemfiles/Gemfile.faraday-1.0 deleted file mode 100644 index 89edfe1c..00000000 --- a/gemfiles/Gemfile.faraday-1.0 +++ /dev/null @@ -1,6 +0,0 @@ -source "https://rubygems.org" - -gemspec :path => "../" - -gem 'activemodel', '~> 4.2.1' -gem 'faraday', '~> 1.0' diff --git a/her.gemspec b/her.gemspec index 17976122..a935337f 100644 --- a/her.gemspec +++ b/her.gemspec @@ -1,4 +1,4 @@ -# -*- encoding: utf-8 -*- +# frozen_string_literal: true $:.push File.expand_path("../lib", __FILE__) require "her/version" @@ -8,21 +8,21 @@ Gem::Specification.new do |s| s.version = Her::VERSION s.authors = ["Rémi Prévost"] s.email = ["remi@exomel.com"] - s.homepage = "http://her-rb.org" + s.homepage = "https://github.com/TwilightCoders/him" s.license = "MIT" s.summary = "A simple Representational State Transfer-based Hypertext Transfer Protocol-powered Object Relational Mapper. Her?" s.description = "Her is an ORM that maps REST resources and collections to Ruby objects" + s.required_ruby_version = ">= 3.1" + s.files = `git ls-files`.split("\n") s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } s.require_paths = ["lib"] - s.add_development_dependency "json", "~> 1.8" - s.add_development_dependency "rake", "~> 10.0" + s.add_development_dependency "rake", ">= 13.0" s.add_development_dependency "rspec", "~> 3.5" - s.add_runtime_dependency "activemodel", ">= 4.2.1" - s.add_runtime_dependency "faraday", ">= 0.8" - s.add_runtime_dependency "multi_json", "~> 1.7" + s.add_runtime_dependency "activemodel", ">= 6.1" + s.add_runtime_dependency "faraday", ">= 2.0" end From 8fd7fc94b8b8649272ac05bfb3c189b08999100a Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 21:34:25 -0600 Subject: [PATCH 02/31] Fix compatibility with modern ActiveSupport and Faraday 2.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AssociationProxy: inherit from BasicObject instead of removed ActiveSupport::ProxyObject/BasicObject, add explicit proxy methods for ==, class, inspect, nil?, etc. - ParseJSON middleware: Faraday::Response::Middleware → Faraday::Middleware - Replace MultiJson.load/dump with stdlib JSON.parse/generate --- lib/her.rb | 2 +- lib/her/middleware/parse_json.rb | 6 +++--- lib/her/model/associations/association_proxy.rb | 5 ++++- spec/api_spec.rb | 10 +++++----- spec/support/extensions/array.rb | 2 +- spec/support/extensions/hash.rb | 2 +- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/lib/her.rb b/lib/her.rb index a34ac837..17df42b5 100644 --- a/lib/her.rb +++ b/lib/her.rb @@ -1,6 +1,6 @@ require "her/version" -require "multi_json" +require "json" require "faraday" require "active_support" require "active_support/inflector" diff --git a/lib/her/middleware/parse_json.rb b/lib/her/middleware/parse_json.rb index 7bae5950..b15021f2 100644 --- a/lib/her/middleware/parse_json.rb +++ b/lib/her/middleware/parse_json.rb @@ -1,6 +1,6 @@ module Her module Middleware - class ParseJSON < Faraday::Response::Middleware + class ParseJSON < Faraday::Middleware # @private def parse_json(body = nil) @@ -8,8 +8,8 @@ def parse_json(body = nil) message = "Response from the API must behave like a Hash or an Array (last JSON response was #{body.inspect})" json = begin - MultiJson.load(body, :symbolize_keys => true) - rescue MultiJson::LoadError + JSON.parse(body, symbolize_names: true) + rescue JSON::ParserError raise Her::Errors::ParseError, message end diff --git a/lib/her/model/associations/association_proxy.rb b/lib/her/model/associations/association_proxy.rb index 36bd2243..682334af 100644 --- a/lib/her/model/associations/association_proxy.rb +++ b/lib/her/model/associations/association_proxy.rb @@ -1,7 +1,7 @@ module Her module Model module Associations - class AssociationProxy < (ActiveSupport.const_defined?('ProxyObject') ? ActiveSupport::ProxyObject : ActiveSupport::BasicObject) + class AssociationProxy < BasicObject # @private def self.install_proxy_methods(target_name, *names) @@ -17,6 +17,9 @@ def #{name}(*args, &block) install_proxy_methods :association, :build, :create, :where, :find, :all, :assign_nested_attributes, :reload + install_proxy_methods 'association.fetch', + :class, :inspect, :==, :kind_of?, :is_a?, :respond_to?, :nil?, :hash, :eql? + # @private def initialize(association) @_her_association = association diff --git a/spec/api_spec.rb b/spec/api_spec.rb index 6bfd4093..5348859a 100644 --- a/spec/api_spec.rb +++ b/spec/api_spec.rb @@ -32,7 +32,7 @@ class Bar; end describe "#request" do before do - class SimpleParser < Faraday::Response::Middleware + class SimpleParser < Faraday::Middleware def on_complete(env) env[:body] = { data: env[:body] } @@ -71,7 +71,7 @@ def on_complete(env) subject.setup url: "https://api.example.com" do |builder| builder.use Her::Middleware::FirstLevelParseJSON builder.adapter :test do |stub| - 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 })] } + stub.get("/users/1") { [200, {}, JSON.generate(id: 1, name: "George Michael Bluth", errors: ["This is a single error"], metadata: { page: 1, per_page: 10 })] } end end end @@ -86,10 +86,10 @@ def on_complete(env) context "parsing a request with a custom parser" do let(:parsed_data) { subject.request(_method: :get, _path: "users/1")[:parsed_data] } before do - class CustomParser < Faraday::Response::Middleware + class CustomParser < Faraday::Middleware def on_complete(env) - json = MultiJson.load(env[:body], symbolize_keys: true) + json = JSON.parse(env[:body], symbolize_names: true) errors = json.delete(:errors) || [] metadata = json.delete(:metadata) || {} env[:body] = { @@ -104,7 +104,7 @@ def on_complete(env) builder.use CustomParser builder.use Faraday::Request::UrlEncoded builder.adapter :test do |stub| - stub.get("/users/1") { [200, {}, MultiJson.dump(id: 1, name: "George Michael Bluth")] } + stub.get("/users/1") { [200, {}, JSON.generate(id: 1, name: "George Michael Bluth")] } end end end diff --git a/spec/support/extensions/array.rb b/spec/support/extensions/array.rb index 9c938e33..f1f48e98 100644 --- a/spec/support/extensions/array.rb +++ b/spec/support/extensions/array.rb @@ -1,6 +1,6 @@ class Array def to_json - MultiJson.dump(self) + JSON.generate(self) end end diff --git a/spec/support/extensions/hash.rb b/spec/support/extensions/hash.rb index 45c552ab..96879698 100644 --- a/spec/support/extensions/hash.rb +++ b/spec/support/extensions/hash.rb @@ -1,6 +1,6 @@ class Hash def to_json - MultiJson.dump(self) + JSON.generate(self) end end From 8277697629c1fe4b863370e8e097cea5026536a3 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 21:47:07 -0600 Subject: [PATCH 03/31] Fix typos throughout codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref: remi/her#551 - functionnality → functionality - corrsponding → corresponding - substracts → subtracts - embeded_params → embedded_params - immediatly → immediately --- lib/her/model/base.rb | 4 ++-- lib/her/model/orm.rb | 2 +- lib/her/model/parse.rb | 4 ++-- spec/model/relation_spec.rb | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/her/model/base.rb b/lib/her/model/base.rb index fe08f839..d44c3f7c 100644 --- a/lib/her/model/base.rb +++ b/lib/her/model/base.rb @@ -1,6 +1,6 @@ module Her module Model - # This module includes basic functionnality to Her::Model + # This module includes basic functionality to Her::Model module Base extend ActiveSupport::Concern @@ -16,7 +16,7 @@ def has_key?(attribute_name) # Returns # * the value of the attribute_name attribute if it's in orm data - # * the resource/collection corrsponding to attribute_name if it's an association + # * the resource/collection corresponding to attribute_name if it's an association # # @private def [](attribute_name) diff --git a/lib/her/model/orm.rb b/lib/her/model/orm.rb index e15fc666..0b251f75 100644 --- a/lib/her/model/orm.rb +++ b/lib/her/model/orm.rb @@ -105,7 +105,7 @@ def increment!(attribute, by = 1) self end - # Initializes +attribute+ to zero if +nil+ and substracts the value passed as + # Initializes +attribute+ to zero if +nil+ and subtracts the value passed as # +by+ (default is 1). The decrement is performed directly on the # underlying attribute, no setter is invoked. Only makes sense for # number-based attributes. Returns +self+. diff --git a/lib/her/model/parse.rb b/lib/her/model/parse.rb index 51b4a31a..e59a99c1 100644 --- a/lib/her/model/parse.rb +++ b/lib/her/model/parse.rb @@ -42,7 +42,7 @@ def to_params(attributes, changes = {}) memo[key.to_sym] = value end - filtered_attributes.merge!(embeded_params(attributes)) + filtered_attributes.merge!(embedded_params(attributes)) if her_api.options[:send_only_modified_attributes] filtered_attributes.slice! *changes.keys.map(&:to_sym) @@ -60,7 +60,7 @@ def to_params(attributes, changes = {}) end # @private - def embeded_params(attributes) + def embedded_params(attributes) associations.values.flatten.each_with_object({}) do |definition, hash| value = case association = attributes[definition[:name]] when Her::Collection, Array diff --git a/spec/model/relation_spec.rb b/spec/model/relation_spec.rb index b8bd658c..1b430ea6 100644 --- a/spec/model/relation_spec.rb +++ b/spec/model/relation_spec.rb @@ -31,7 +31,7 @@ spawn_model "Foo::User" end - it "doesn't fetch the data immediatly" do + it "doesn't fetch the data immediately" do expect(Foo::User).to receive(:request).never @users = Foo::User.where(admin: 1) end From 2751977f01c61bf438b35498244a02ae3309b549 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 21:49:26 -0600 Subject: [PATCH 04/31] Implement attribute_changed_in_place? for dirty tracking Ref: remi/her#383 Closes: remi/her#382 Override ActiveModel::Dirty's attribute_changed_in_place? to use Her's change tracking, enabling validates_numericality_of and other validators that depend on in-place change detection. --- lib/her/model.rb | 6 ++++++ spec/model/attributes_spec.rb | 6 ++++++ spec/model/validations_spec.rb | 4 +++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/her/model.rb b/lib/her/model.rb index 43929e5e..f026af66 100644 --- a/lib/her/model.rb +++ b/lib/her/model.rb @@ -44,6 +44,12 @@ module Model include ActiveModel::Conversion include ActiveModel::Dirty + # Override ActiveModel::Dirty's attribute_changed_in_place? to use + # Her's change tracking. This allows validates_numericality_of to work. + def attribute_changed_in_place?(attribute_name) + !changes[attribute_name.to_s].nil? + end + # Class methods included do # Assign the default API diff --git a/spec/model/attributes_spec.rb b/spec/model/attributes_spec.rb index 64f9d9ff..5c21e844 100644 --- a/spec/model/attributes_spec.rb +++ b/spec/model/attributes_spec.rb @@ -74,6 +74,12 @@ expect(@new_user.get_attribute(:unknown_method_for_a_user)).to be_nil expect(@new_user.get_attribute(:'life-span')).to eq("3 years") end + + it "handles attribute_changed_in_place?" do + @new_user = Foo::User.new + @new_user.fullname = "Schmoo" + expect(@new_user.attribute_changed_in_place?(:fullname)).to be_truthy + end end context "assigning new resource data" do diff --git a/spec/model/validations_spec.rb b/spec/model/validations_spec.rb index 511e5fb5..cd7663cb 100644 --- a/spec/model/validations_spec.rb +++ b/spec/model/validations_spec.rb @@ -6,9 +6,10 @@ context "validating attributes" do before do spawn_model "Foo::User" do - attributes :fullname, :email + attributes :fullname, :email, :age validates_presence_of :fullname validates_presence_of :email + validates_numericality_of :age end end @@ -19,6 +20,7 @@ expect(user.errors.full_messages).to include("Email can't be blank") user.fullname = "Tobias Fünke" user.email = "tobias@bluthcompany.com" + user.age = 35 expect(user).to be_valid end end From 892dbce3e3e329aacb9f2c3bf7c6607f6107ef64 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 21:49:59 -0600 Subject: [PATCH 05/31] Add "class" to reserved attribute words Ref: remi/her#372 Closes: remi/her#371 Prevents assign_attributes(class: 'value') from defining a getter that overrides Object#class on the model instance. --- lib/her/model/attributes.rb | 2 +- spec/model/attributes_spec.rb | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/her/model/attributes.rb b/lib/her/model/attributes.rb index 38759312..1b65f133 100644 --- a/lib/her/model/attributes.rb +++ b/lib/her/model/attributes.rb @@ -206,7 +206,7 @@ def new_from_parsed_data(parsed_data) # # @private def use_setter_methods(model, params = {}) - reserved = [:id, model.class.primary_key, *model.class.association_keys] + reserved = [:id, :class, model.class.primary_key, *model.class.association_keys] model.class.attributes *params.keys.reject { |k| reserved.include?(k) } setter_method_names = model.class.setter_method_names diff --git a/spec/model/attributes_spec.rb b/spec/model/attributes_spec.rb index 5c21e844..7e497207 100644 --- a/spec/model/attributes_spec.rb +++ b/spec/model/attributes_spec.rb @@ -318,6 +318,12 @@ def document? expect(user.fullname?).to be_truthy end + it "does not define a getter for attributes matching reserved words" do + user = Foo::User.new + user.assign_attributes(class: "klass") + expect(user.class.name).to eq("Foo::User") + end + context "when attribute methods are already defined" do before do class AbstractUser From c0330e7dcb2b10fdeb6586d57e82598b87228dca Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 21:51:50 -0600 Subject: [PATCH 06/31] Handle empty 204 responses on has_many associations Ref: remi/her#540 Route has_many fetches through get_collection instead of get, and guard instantiate_collection against blank response data. This prevents crashes when an API returns 204 No Content for an association endpoint. --- lib/her/model/associations/association.rb | 11 ++++++++++- lib/her/model/attributes.rb | 4 +++- spec/model/associations_spec.rb | 12 ++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/lib/her/model/associations/association.rb b/lib/her/model/associations/association.rb index 3548dc80..5e39ceef 100644 --- a/lib/her/model/associations/association.rb +++ b/lib/her/model/associations/association.rb @@ -49,11 +49,20 @@ def fetch(opts = {}) return @opts[:default].try(:dup) if @parent.new? path = build_association_path -> { "#{@parent.request_path(@params)}#{@opts[:path]}" } - @klass.get(path, @params).tap do |result| + request_association(path, @params).tap do |result| @cached_result = result unless @params.any? end end + # @private + def request_association(path, params) + if @opts[:default].is_a?(Array) + @klass.get_collection(path, params) + else + @klass.get(path, params) + end + end + # @private def build_association_path(code) instance_exec(&code) diff --git a/lib/her/model/attributes.rb b/lib/her/model/attributes.rb index 1b65f133..693cb1c3 100644 --- a/lib/her/model/attributes.rb +++ b/lib/her/model/attributes.rb @@ -179,7 +179,9 @@ def instantiate_record(klass, parsed_data) # # @private def instantiate_collection(klass, parsed_data = {}) - records = klass.extract_array(parsed_data).map do |record| + raw_data = klass.extract_array(parsed_data) + raw_data = [] if raw_data.blank? + records = raw_data.map do |record| instantiate_record(klass, data: record) end Her::Collection.new(records, parsed_data[:metadata], parsed_data[:errors]) diff --git a/spec/model/associations_spec.rb b/spec/model/associations_spec.rb index e44eb712..44114842 100644 --- a/spec/model/associations_spec.rb +++ b/spec/model/associations_spec.rb @@ -279,6 +279,7 @@ before do spawn_model "Foo::User" do has_many :comments, class_name: "Foo::Comment" + has_many :feeds, class_name: "Foo::Feed" has_one :role, class_name: "Foo::Role" belongs_to :organization, class_name: "Foo::Organization" has_many :posts, inverse_of: :admin @@ -289,6 +290,10 @@ parse_root_in_json true end + spawn_model "Foo::Feed" do + belongs_to :user + end + spawn_model "Foo::Post" do belongs_to :admin, class_name: "Foo::User" end @@ -308,6 +313,7 @@ builder.adapter :test do |stub| stub.get("/users/1") { [200, {}, { id: 1, name: "Tobias Fünke", comments: [{ comment: { id: 2, body: "Tobias, you blow hard!", user_id: 1 } }, { comment: { id: 3, body: "I wouldn't mind kissing that man between the cheeks, so to speak", user_id: 1 } }], role: { id: 1, body: "Admin" }, organization: { id: 1, name: "Bluth Company" }, organization_id: 1 }.to_json] } stub.get("/users/1/comments") { [200, {}, [{ comment: { id: 4, body: "They're having a FIRESALE?" } }].to_json] } + stub.get("/users/1/feeds") { [204, {}, ""] } stub.get("/users/1/role") { [200, {}, { id: 3, body: "User" }.to_json] } stub.get("/users/1/posts") { [200, {}, [{ id: 1, body: "blogging stuff", admin_id: 1 }].to_json] } stub.get("/organizations/1") { [200, {}, { organization: { id: 1, name: "Bluth Company Foo" } }.to_json] } @@ -325,6 +331,12 @@ expect(user.comments.first.body).to eq("Tobias, you blow hard!") end + it "handles a 204 empty response on has_many" do + expect(user.feeds).to eq([]) + expect(user.feeds.first).to eq(nil) + expect(user.feeds.length).to eq(0) + end + it "does not refetch the parents models data if they have been fetched before" do expect(user.comments.first.user.object_id).to eq(user.object_id) end From 74bc996b829efc408da0461da85e03763e67cead Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 21:54:31 -0600 Subject: [PATCH 07/31] Cache inverse of has_many through association proxy Ref: remi/her#292 When fetching has_many associations, set the inverse parent through the association's cached_result rather than injecting it directly into the child's attributes hash. This prevents the parent object from appearing as a serializable attribute on child records. --- lib/her/model/associations/association.rb | 2 +- lib/her/model/associations/has_many_association.rb | 9 ++++++++- spec/model/associations_spec.rb | 5 +++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/her/model/associations/association.rb b/lib/her/model/associations/association.rb index 5e39ceef..a77c9bbc 100644 --- a/lib/her/model/associations/association.rb +++ b/lib/her/model/associations/association.rb @@ -4,7 +4,7 @@ module Associations class Association # @private - attr_accessor :params + attr_accessor :params, :cached_result # @private def initialize(parent, opts = {}) diff --git a/lib/her/model/associations/has_many_association.rb b/lib/her/model/associations/has_many_association.rb index 23ed797a..17069cd1 100644 --- a/lib/her/model/associations/has_many_association.rb +++ b/lib/her/model/associations/has_many_association.rb @@ -85,7 +85,14 @@ def create(attributes = {}) def fetch super.tap do |o| inverse_of = @opts[:inverse_of] || @parent.singularized_resource_name - o.each { |entry| entry.attributes[inverse_of] = @parent } + o.each do |entry| + inverse_association = entry.send(inverse_of).association rescue nil + if inverse_association.present? + inverse_association.cached_result = @parent + else + entry.attributes[inverse_of] = @parent + end + end end end diff --git a/spec/model/associations_spec.rb b/spec/model/associations_spec.rb index 44114842..533936c7 100644 --- a/spec/model/associations_spec.rb +++ b/spec/model/associations_spec.rb @@ -345,6 +345,11 @@ expect(user.posts.first.admin.object_id).to eq(user.object_id) end + it "does not set the inverse of a has_many as an attribute" do + first_comment = user.comments.where(foo_id: 1).first + expect(first_comment.attributes.keys).not_to include("user") + end + it "fetches has_many data even if it was included, only if called with parameters" do expect(user.comments.where(foo_id: 1).length).to eq(1) end From 459845af42988f75279a4d5fa45bf9824829aa9b Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 21:55:59 -0600 Subject: [PATCH 08/31] Replace setter methods cache with respond_to_without_missing? Ref: remi/her#473 Drop the memoized setter_method_names Set in favor of a respond_to_without_missing? method that checks for real setter methods without triggering method_missing. This avoids stale cache issues and simplifies the setter detection logic. --- lib/her/model/attributes.rb | 22 +++++++++++----------- spec/model/attributes_spec.rb | 12 ++++++++++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/lib/her/model/attributes.rb b/lib/her/model/attributes.rb index 693cb1c3..0bea1b6d 100644 --- a/lib/her/model/attributes.rb +++ b/lib/her/model/attributes.rb @@ -55,9 +55,19 @@ def method_missing(method, *args, &blk) # @private def respond_to_missing?(method, include_private = false) + return false if Thread.current[:her_respond_to_missing] method.to_s =~ /[?=]$/ || @_her_attributes.include?(method) || super end + # Check if the model responds to a method without considering + # method_missing-based dynamic attributes. + def respond_to_without_missing?(method, include_private = false) + Thread.current[:her_respond_to_missing] = true + respond_to?(method, include_private) + ensure + Thread.current[:her_respond_to_missing] = false + end + # Assign new attributes to a resource # # @example @@ -211,10 +221,9 @@ def use_setter_methods(model, params = {}) reserved = [:id, :class, model.class.primary_key, *model.class.association_keys] model.class.attributes *params.keys.reject { |k| reserved.include?(k) } - setter_method_names = model.class.setter_method_names params.each_with_object({}) do |(key, value), memo| setter_method = "#{key}=" - if setter_method_names.include?(setter_method) + if model.respond_to_without_missing?(setter_method) model.send setter_method, value else memo[key.to_sym] = value @@ -288,15 +297,6 @@ def store_metadata(value = nil) store_her_data(:metadata, value) end - # @private - def setter_method_names - @_her_setter_method_names ||= begin - instance_methods.each_with_object(Set.new) do |method, memo| - memo << method.to_s if method.to_s.end_with?('=') - end - end - end - private # @private diff --git a/spec/model/attributes_spec.rb b/spec/model/attributes_spec.rb index 7e497207..73985b24 100644 --- a/spec/model/attributes_spec.rb +++ b/spec/model/attributes_spec.rb @@ -252,6 +252,18 @@ def document=(document) @user = Foo::User.find(1) expect(@user.document).to eq("http://example.com") end + + it "exposes the method to respond_to? and respond_to_without_missing?" do + @user = Foo::User.find(1) + expect(@user.respond_to?(:document=)).to be_truthy + expect(@user.respond_to_without_missing?(:document=)).to be_truthy + end + + it "exposes a non-existent method to respond_to? but not respond_to_without_missing?" do + @user = Foo::User.find(1) + expect(@user.respond_to?(:nonexistent=)).to be_truthy + expect(@user.respond_to_without_missing?(:nonexistent=)).to be_falsey + end end context "for predicate method" do From 484e5d394cfc9207acb826a3108f202f53686586 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 21:59:39 -0600 Subject: [PATCH 09/31] Return empty collection when where(:foo => []) is given Ref: remi/her#412 Short-circuit to an empty Her::Collection when any query parameter is an empty array, avoiding a pointless HTTP request that would return all records instead of none. --- lib/her/model/associations/association.rb | 10 +++++++--- lib/her/model/relation.rb | 12 ++++++++---- spec/model/relation_spec.rb | 7 +++++++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/lib/her/model/associations/association.rb b/lib/her/model/associations/association.rb index a77c9bbc..d90a6fc4 100644 --- a/lib/her/model/associations/association.rb +++ b/lib/her/model/associations/association.rb @@ -48,9 +48,13 @@ def fetch(opts = {}) return @parent.attributes[@name] unless @params.any? || @parent.attributes[@name].blank? return @opts[:default].try(:dup) if @parent.new? - path = build_association_path -> { "#{@parent.request_path(@params)}#{@opts[:path]}" } - request_association(path, @params).tap do |result| - @cached_result = result unless @params.any? + if @params.values.include?([]) && !is_a?(HasOneAssociation) + Her::Collection.new + else + path = build_association_path -> { "#{@parent.request_path(@params)}#{@opts[:path]}" } + request_association(path, @params).tap do |result| + @cached_result = result unless @params.any? + end end end diff --git a/lib/her/model/relation.rb b/lib/her/model/relation.rb index df476392..b6a93b9e 100644 --- a/lib/her/model/relation.rb +++ b/lib/her/model/relation.rb @@ -67,10 +67,14 @@ def kind_of?(thing) # @private def fetch @_fetch ||= begin - path = @parent.build_request_path(@parent.collection_path, @params) - method = @parent.method_for(:find) - @parent.request(@params.merge(:_method => method, :_path => path)) do |parsed_data, _| - @parent.new_collection(parsed_data) + if @params.values.include?([]) + Her::Collection.new + else + path = @parent.build_request_path(@parent.collection_path, @params) + method = @parent.method_for(:find) + @parent.request(@params.merge(:_method => method, :_path => path)) do |parsed_data, _| + @parent.new_collection(parsed_data) + end end end end diff --git a/spec/model/relation_spec.rb b/spec/model/relation_spec.rb index 1b430ea6..bfc387e5 100644 --- a/spec/model/relation_spec.rb +++ b/spec/model/relation_spec.rb @@ -60,6 +60,13 @@ expect(Foo::User.create(fullname: "George Michael Bluth").id).to eq(3) expect(Foo::User.all.size).to eql 3 end + + it "returns an empty collection without fetching when [] is given" do + expect(Foo::User).not_to receive(:request) + users = Foo::User.where(fullname: []) + expect(users).to respond_to(:length) + expect(users.size).to eql 0 + end end context "for parent class" do From 1828781530c9491ad9197dd3444998d10631c3db Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 22:07:08 -0600 Subject: [PATCH 10/31] Add regression tests for cyclic association serialization Ref: remi/her#539 The infinite loop reported in #539 is already prevented by caching the inverse association through the proxy (commit 74bc996) rather than injecting it into the attributes hash. These tests confirm the fix holds for to_params with bidirectional associations. --- spec/model/parse_spec.rb | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/spec/model/parse_spec.rb b/spec/model/parse_spec.rb index 25b617a8..75006eb7 100644 --- a/spec/model/parse_spec.rb +++ b/spec/model/parse_spec.rb @@ -358,6 +358,39 @@ def to_params end end + context "when associations reference each other" do + before do + Her::API.setup url: "https://api.example.com" do |builder| + builder.use Her::Middleware::FirstLevelParseJSON + builder.use Faraday::Request::UrlEncoded + end + + Her::API.default_api.connection.adapter :test do |stub| + stub.get("/users/1") { |env| [200, {}, { id: 1, name: "Tobias", comments: [{ id: 2, body: "Hey!", user_id: 1 }] }.to_json] } + end + + spawn_model "Foo::User" do + has_many :comments, class_name: "Foo::Comment" + end + + spawn_model "Foo::Comment" do + belongs_to :user, class_name: "Foo::User" + end + end + + it "does not infinitely loop when calling to_params with cyclic associations" do + user = Foo::User.find(1) + expect { Timeout.timeout(5) { user.to_params } }.not_to raise_error + end + + it "does not include belongs_to parent data in nested to_params" do + user = Foo::User.find(1) + params = user.to_params + comment_params = params[:comments].first + expect(comment_params).not_to have_key(:user) + end + end + context "when parse_root_in_json set json_api to true" do before do Her::API.setup url: "https://api.example.com" do |builder| From f6b6775730b4c974ab4ef412687c32014b6300bc Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 22:08:09 -0600 Subject: [PATCH 11/31] Add JSON API compound document support Ref: remi/her#360 Closes: remi/her#353 Resolve JSON API relationships against included resources in the JsonApiParser middleware. When a response contains both `relationships` and `included` keys, the parser matches resource linkages by type+id and merges the resolved data into each resource's attributes, enabling has_many/belongs_to associations to be populated without additional HTTP requests. --- lib/her/middleware/json_api_parser.rb | 34 +++++++++- spec/json_api/model_spec.rb | 91 +++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 3 deletions(-) diff --git a/lib/her/middleware/json_api_parser.rb b/lib/her/middleware/json_api_parser.rb index 51e7baab..9b091165 100644 --- a/lib/her/middleware/json_api_parser.rb +++ b/lib/her/middleware/json_api_parser.rb @@ -12,13 +12,41 @@ class JsonApiParser < ParseJSON def parse(body) json = parse_json(body) + included = json.fetch(:included, []) + primary_data = json.fetch(:data, {}) + Array(primary_data).each do |resource| + next unless resource.is_a?(Hash) + relationships = resource.delete(:relationships) { {} } + resource[:attributes].merge!(resolve_relationships(relationships, included)) + end + { - :data => json[:data] || {}, - :errors => json[:errors] || [], - :metadata => json[:meta] || {}, + data: primary_data || {}, + errors: json[:errors] || [], + metadata: json[:meta] || {} } end + private + + def resolve_relationships(relationships, included) + return {} if included.empty? || relationships.nil? + relationships.each_with_object({}) do |(rel_name, linkage), built| + linkage_data = linkage.fetch(:data, {}) + built[rel_name] = if linkage_data.is_a?(Array) + linkage_data.map { |l| find_included(l, included) }.compact + else + find_included(linkage_data, included) + end + end + end + + def find_included(linkage, included) + included.detect { |i| i.values_at(:id, :type) == linkage.values_at(:id, :type) } + end + + public + # This method is triggered when the response has been received. It modifies # the value of `env[:body]`. # diff --git a/spec/json_api/model_spec.rb b/spec/json_api/model_spec.rb index 019e34ec..e9c26b28 100644 --- a/spec/json_api/model_spec.rb +++ b/spec/json_api/model_spec.rb @@ -166,4 +166,95 @@ end end end + + context "compound document" do + before do + Her::API.setup url: "https://api.example.com" do |connection| + connection.use Her::Middleware::JsonApiParser + connection.adapter :test do |stub| + stub.get("/players") do + [ + 200, + {}, + { + data: [ + { + id: 1, + type: "players", + attributes: { name: "Roger Federer" }, + relationships: { + sponsors: { + data: [ + { type: "sponsors", id: 1 }, + { type: "sponsors", id: 2 } + ] + }, + racquet: { + data: { type: "racquets", id: 1 } + } + } + }, + { + id: 2, + type: "players", + attributes: { name: "Kei Nishikori" }, + relationships: { + sponsors: { + data: [ + { type: "sponsors", id: 2 }, + { type: "sponsors", id: 3 } + ] + }, + racquet: { + data: { type: "racquets", id: 2 } + } + } + }, + { + id: 3, + type: "players", + attributes: { name: "Hubert Huang", racquet_id: nil }, + relationships: {} + } + ], + included: [ + { type: "sponsors", id: 1, attributes: { company: "Nike" } }, + { type: "sponsors", id: 2, attributes: { company: "Wilson" } }, + { type: "sponsors", id: 3, attributes: { company: "Uniqlo" } }, + { type: "racquets", id: 1, attributes: { name: "Wilson Pro Staff" } }, + { type: "racquets", id: 2, attributes: { name: "Wilson Steam" } } + ] + }.to_json + ] + end + + stub.get("/players/3/sponsors") do + [200, {}, { data: [] }.to_json] + end + end + end + + spawn_model("Foo::Sponsor", type: Her::JsonApi::Model) + spawn_model("Foo::Racquet", type: Her::JsonApi::Model) + spawn_model("Foo::Player", type: Her::JsonApi::Model) do + has_many :sponsors + belongs_to :racquet + end + end + + it "parses included resources into associations from compound documents" do + players = Foo::Player.all.to_a + fed = players.detect { |p| p.name == "Roger Federer" } + expect(fed.sponsors.map(&:company)).to match_array ["Nike", "Wilson"] + expect(fed.racquet.name).to eq "Wilson Pro Staff" + + kei = players.detect { |p| p.name == "Kei Nishikori" } + expect(kei.sponsors.map(&:company)).to match_array ["Uniqlo", "Wilson"] + expect(kei.racquet.name).to eq "Wilson Steam" + + hubert = players.detect { |p| p.name == "Hubert Huang" } + expect(hubert.sponsors).to eq [] + expect(hubert.racquet).to be_nil + end + end end From 0e0e8e84c94251b3c3c2b78521ca765b9923caa3 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 22:11:38 -0600 Subject: [PATCH 12/31] Fix dup/clone sharing mutable state between copies Closes: remi/her#355 Add initialize_copy to deep-dup the attributes hash, metadata, and response_errors so that modifying a dup'd or clone'd model does not mutate the original. --- lib/her/model/attributes.rb | 10 ++++++++++ spec/model_spec.rb | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/lib/her/model/attributes.rb b/lib/her/model/attributes.rb index 0bea1b6d..1dd05efc 100644 --- a/lib/her/model/attributes.rb +++ b/lib/her/model/attributes.rb @@ -35,6 +35,16 @@ def initialize(attributes = {}) run_callbacks :initialize end + # Ensure dup/clone creates a deep copy of mutable state + # + # @private + def initialize_copy(other) + super + @_her_attributes = other.attributes.dup + @metadata = other.metadata.dup if other.metadata + @response_errors = other.response_errors.dup if other.response_errors + end + # Handles missing methods # # @private diff --git a/spec/model_spec.rb b/spec/model_spec.rb index 4031950e..74727b8b 100644 --- a/spec/model_spec.rb +++ b/spec/model_spec.rb @@ -42,4 +42,14 @@ specify { expect(subject[:name]).to eq("Tobias Fünke") } specify { expect(subject[:comments].first.body).to eq("They're having a FIRESALE?") } end + + describe :dup do + it "does not share mutable state between original and copy" do + original = Foo::User.find(1) + copy = original.dup + copy.name = "Lindsay Fünke" + expect(original.name).to eq("Tobias Fünke") + expect(copy.name).to eq("Lindsay Fünke") + end + end end From 392affcda5282fbc849f4cd4c3c44df84a017239 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 22:12:29 -0600 Subject: [PATCH 13/31] Add test for blank? on nil association proxies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref: remi/her#315, remi/her#187 AssociationProxy delegates nil? and blank? correctly for nil associations. Ruby truthiness (if proxy) cannot be overridden by the proxy pattern — use .nil? or .blank? instead. --- spec/model/associations_spec.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/spec/model/associations_spec.rb b/spec/model/associations_spec.rb index 533936c7..c62f279a 100644 --- a/spec/model/associations_spec.rb +++ b/spec/model/associations_spec.rb @@ -502,6 +502,11 @@ expect(new_user.role).to be_nil expect(new_user.organization).to be_nil end + + it "reports nil associations as blank" do + expect(new_user.role.blank?).to be true + expect(new_user.organization.blank?).to be true + end end context "when foreign_key is nil" do From 66263f2042e5b35792fd10d569d18655a0f670c1 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 22:15:18 -0600 Subject: [PATCH 14/31] Track new record state explicitly instead of checking id Closes: remi/her#494 Replace the id.nil? check in new?/persisted? with an explicit @_her_new_record flag. This correctly handles models initialized with a pre-assigned id (e.g. User.new(id: 5)) which should still be considered new/unpersisted until saved. --- lib/her/model/attributes.rb | 4 +++- lib/her/model/orm.rb | 10 ++++++---- spec/model/orm_spec.rb | 6 ++++++ 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/her/model/attributes.rb b/lib/her/model/attributes.rb index 1dd05efc..f25f7c5b 100644 --- a/lib/her/model/attributes.rb +++ b/lib/her/model/attributes.rb @@ -28,6 +28,7 @@ def initialize(attributes = {}) @metadata = attributes.delete(:_metadata) || {} @response_errors = attributes.delete(:_errors) || {} @destroyed = attributes.delete(:_destroyed) || false + @_her_new_record = attributes.delete(:_new_record) { true } attributes = self.class.default_scope.apply_to(attributes) assign_attributes(attributes) @@ -187,7 +188,8 @@ def instantiate_record(klass, parsed_data) record else attributes = klass.parse(record).merge(_metadata: parsed_data[:metadata], - _errors: parsed_data[:errors]) + _errors: parsed_data[:errors], + _new_record: false) klass.new(attributes).tap do |record_instance| record_instance.send :clear_changes_information record_instance.run_callbacks :find diff --git a/lib/her/model/orm.rb b/lib/her/model/orm.rb index 0b251f75..70b20740 100644 --- a/lib/her/model/orm.rb +++ b/lib/her/model/orm.rb @@ -8,7 +8,7 @@ module ORM # Return `true` if a resource was not saved yet def new? - id.nil? + @_her_new_record end alias new_record? new? @@ -42,9 +42,10 @@ def save run_callbacks :save do run_callbacks callback do - self.class.request(to_params.merge(:_method => method, :_path => request_path)) do |parsed_data, response| + self.class.request(to_params.merge(_method: method, _path: request_path)) do |parsed_data, response| load_from_parsed_data(parsed_data) return false if !response.success? || @response_errors.any? + @_her_new_record = false changes_applied end end @@ -235,7 +236,7 @@ def save_existing(id, params) # Similar to .save_existing but raises ResourceInvalid if save fails def save_existing!(id, params) - resource = new(params.merge(primary_key => id)) + resource = new(params.merge(primary_key => id, _new_record: false)) resource.save! resource end @@ -280,9 +281,10 @@ def build(attributes = {}) method = method_for(:new) resource = nil - request(params.merge(:_method => method, :_path => path)) do |parsed_data, response| + request(params.merge(_method: method, _path: path)) do |parsed_data, response| if response.success? resource = new_from_parsed_data(parsed_data) + resource.instance_variable_set(:@_her_new_record, true) end end resource diff --git a/spec/model/orm_spec.rb b/spec/model/orm_spec.rb index 7bd5a3fa..0cbb4a4b 100644 --- a/spec/model/orm_spec.rb +++ b/spec/model/orm_spec.rb @@ -58,6 +58,12 @@ expect(@existing_user.new_record?).to be_falsey end + it "treats new resource with pre-assigned id as new" do + @user = Foo::User.new(id: 5, fullname: "Tobias Fünke") + expect(@user.new?).to be_truthy + expect(@user.persisted?).to be_falsey + end + it "handles new resource with custom primary key" do @new_user = Foo::AdminUser.new(fullname: "Lindsay Fünke", id: -1) expect(@new_user).to be_new From ce1d246e3f944d546945ef0013111173bbc5841d Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 22:18:56 -0600 Subject: [PATCH 15/31] POST to collection path for new records with pre-assigned id Closes: remi/her#176 When saving a new record, use the collection path instead of the resource path so that Model.create(id: '1234') correctly POSTs to /models rather than /models/1234. This was made possible by the explicit new record tracking from the previous commit. --- lib/her/model/orm.rb | 7 ++++++- spec/model/orm_spec.rb | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/her/model/orm.rb b/lib/her/model/orm.rb index 70b20740..8ff30280 100644 --- a/lib/her/model/orm.rb +++ b/lib/her/model/orm.rb @@ -39,10 +39,15 @@ def destroyed? def save callback = new? ? :create : :update method = self.class.method_for(callback) + path = if new? + self.class.build_request_path(self.class.collection_path, attributes.except(self.class.primary_key)) + else + request_path + end run_callbacks :save do run_callbacks callback do - self.class.request(to_params.merge(_method: method, _path: request_path)) do |parsed_data, response| + self.class.request(to_params.merge(_method: method, _path: path)) do |parsed_data, response| load_from_parsed_data(parsed_data) return false if !response.success? || @response_errors.any? @_her_new_record = false diff --git a/spec/model/orm_spec.rb b/spec/model/orm_spec.rb index 0cbb4a4b..dd12b236 100644 --- a/spec/model/orm_spec.rb +++ b/spec/model/orm_spec.rb @@ -71,6 +71,7 @@ @existing_user = Foo::AdminUser.find(1) expect(@existing_user).not_to be_new end + end context "mapping data, metadata and error data to Ruby objects" do @@ -398,6 +399,12 @@ def friends expect(@company.save).to be_falsey expect(@company.name).to eq("Company Inc.") end + + it "POSTs to collection path even with a pre-assigned id" do + @user = Foo::User.create(id: 1234, fullname: "Tobias Fünke") + expect(@user.fullname).to eq("Tobias Fünke") + expect(@user).to be_persisted + end end context "updating resources" do From 7089eb51bcfdd0d35214e635bb6868c709825f94 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 22:25:45 -0600 Subject: [PATCH 16/31] Use declared foreign_key in has_many build Closes: remi/her#288 has_many#build now respects the foreign_key option instead of always inferring it from the parent's class name. This fixes cases like has_many :articles, foreign_key: :creator_id where the foreign key differs from convention. --- lib/her/model/associations/has_many_association.rb | 3 ++- spec/model/associations_spec.rb | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/her/model/associations/has_many_association.rb b/lib/her/model/associations/has_many_association.rb index 17069cd1..1bba8b67 100644 --- a/lib/her/model/associations/has_many_association.rb +++ b/lib/her/model/associations/has_many_association.rb @@ -52,7 +52,8 @@ def self.parse(association, klass, data) # TODO: This only merges the id of the parents, handle the case # where this is more deeply nested def build(attributes = {}) - @klass.build(attributes.merge(:"#{@parent.singularized_resource_name}_id" => @parent.id)) + foreign_key = @opts[:foreign_key] || :"#{@parent.singularized_resource_name}_id" + @klass.build(attributes.merge(foreign_key => @parent.id)) end # Create a new object, save it and add it to the associated collection diff --git a/spec/model/associations_spec.rb b/spec/model/associations_spec.rb index c62f279a..735cb345 100644 --- a/spec/model/associations_spec.rb +++ b/spec/model/associations_spec.rb @@ -884,6 +884,20 @@ def present? end end + context "with #build using custom foreign_key" do + before do + spawn_model "Foo::Article" + spawn_model "Foo::User" do + has_many :articles, foreign_key: :creator_id + end + end + + it "uses the declared foreign_key instead of convention" do + article = Foo::User.new(id: 5).articles.build(title: "Hello") + expect(article.creator_id).to eq(5) + end + end + context "with #create" do let(:user) { Foo::User.find(10) } let(:comment) { user.comments.create(body: "Hello!") } From d37ed41ed4544f1535a1eb0bafcce9d385701692 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 22:27:35 -0600 Subject: [PATCH 17/31] Add "attributes" to reserved words, fix collision crash Closes: remi/her#145 When API response data contains a key named "attributes", it collides with ActiveModel's internal attributes method, causing a crash during initialization. Filter reserved keys before calling respond_to_without_missing? to prevent the collision. --- lib/her/model/attributes.rb | 7 ++++--- spec/model/attributes_spec.rb | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/her/model/attributes.rb b/lib/her/model/attributes.rb index f25f7c5b..42eeaffc 100644 --- a/lib/her/model/attributes.rb +++ b/lib/her/model/attributes.rb @@ -230,10 +230,11 @@ def new_from_parsed_data(parsed_data) # # @private def use_setter_methods(model, params = {}) - reserved = [:id, :class, model.class.primary_key, *model.class.association_keys] - model.class.attributes *params.keys.reject { |k| reserved.include?(k) } + reserved = [:id, :class, :attributes, model.class.primary_key, *model.class.association_keys] + settable_params = params.reject { |k, _| reserved.include?(k.to_sym) } + model.class.attributes *settable_params.keys - params.each_with_object({}) do |(key, value), memo| + settable_params.each_with_object(params.slice(*reserved)) do |(key, value), memo| setter_method = "#{key}=" if model.respond_to_without_missing?(setter_method) model.send setter_method, value diff --git a/spec/model/attributes_spec.rb b/spec/model/attributes_spec.rb index 73985b24..a57dafed 100644 --- a/spec/model/attributes_spec.rb +++ b/spec/model/attributes_spec.rb @@ -336,6 +336,12 @@ def document? expect(user.class.name).to eq("Foo::User") end + it "does not crash when data includes an 'attributes' key" do + user = Foo::User.new(attributes: { foo: "bar" }) + expect(user.attributes).to be_a(Hash) + expect(user.attributes[:attributes]).to eq("foo" => "bar") + end + context "when attribute methods are already defined" do before do class AbstractUser From cb5ae73384b1cce5094c3dd1e9b7d27a65ba91aa Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 22:28:16 -0600 Subject: [PATCH 18/31] Extract foreign_key helper to Association base class Centralizes the foreign key lookup (respecting :foreign_key option with convention fallback) in Association#foreign_key, replacing duplicated inline logic in has_many and has_one build methods. --- lib/her/model/associations/association.rb | 6 ++++++ lib/her/model/associations/has_many_association.rb | 1 - lib/her/model/associations/has_one_association.rb | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/her/model/associations/association.rb b/lib/her/model/associations/association.rb index d90a6fc4..cf8f8b10 100644 --- a/lib/her/model/associations/association.rb +++ b/lib/her/model/associations/association.rb @@ -16,6 +16,12 @@ def initialize(parent, opts = {}) @name = @opts[:name] end + # Returns the foreign key to use when building child records, + # respecting the :foreign_key option or falling back to convention. + def foreign_key + @opts[:foreign_key]&.to_sym || :"#{@parent.singularized_resource_name}_id" + end + # @private def self.proxy(parent, opts = {}) AssociationProxy.new new(parent, opts) diff --git a/lib/her/model/associations/has_many_association.rb b/lib/her/model/associations/has_many_association.rb index 1bba8b67..c906aac7 100644 --- a/lib/her/model/associations/has_many_association.rb +++ b/lib/her/model/associations/has_many_association.rb @@ -52,7 +52,6 @@ def self.parse(association, klass, data) # TODO: This only merges the id of the parents, handle the case # where this is more deeply nested def build(attributes = {}) - foreign_key = @opts[:foreign_key] || :"#{@parent.singularized_resource_name}_id" @klass.build(attributes.merge(foreign_key => @parent.id)) end diff --git a/lib/her/model/associations/has_one_association.rb b/lib/her/model/associations/has_one_association.rb index 80cb3cd3..f62884f0 100644 --- a/lib/her/model/associations/has_one_association.rb +++ b/lib/her/model/associations/has_one_association.rb @@ -45,7 +45,7 @@ def self.parse(*args) # new_role = user.role.build(:title => "moderator") # new_role # => # def build(attributes = {}) - @klass.build(attributes.merge(:"#{@parent.singularized_resource_name}_id" => @parent.id)) + @klass.build(attributes.merge(foreign_key => @parent.id)) end # Create a new object, save it and associate it to the parent From e844e62e9468019374e1eff9accb41978786f887 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 22:30:51 -0600 Subject: [PATCH 19/31] Handle string-keyed parsed_data from custom middleware Closes: remi/her#396 Symbolize keys on parsed_data when it enters the model layer so that custom middleware or caching layers (e.g. faraday-http-cache) that return string-keyed hashes work correctly. --- lib/her/model/http.rb | 6 ++++-- spec/model/parse_spec.rb | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/lib/her/model/http.rb b/lib/her/model/http.rb index 71c4f344..aba9ee36 100644 --- a/lib/her/model/http.rb +++ b/lib/her/model/http.rb @@ -54,11 +54,13 @@ def use_api(value = nil) # @private def request(params = {}) request = her_api.request(params) + parsed_data = request[:parsed_data] + parsed_data = parsed_data.symbolize_keys if parsed_data.respond_to?(:symbolize_keys) if block_given? - yield request[:parsed_data], request[:response] + yield parsed_data, request[:response] else - { :parsed_data => request[:parsed_data], :response => request[:response] } + { parsed_data: parsed_data, response: request[:response] } end end diff --git a/spec/model/parse_spec.rb b/spec/model/parse_spec.rb index 75006eb7..fd5b8eba 100644 --- a/spec/model/parse_spec.rb +++ b/spec/model/parse_spec.rb @@ -391,6 +391,39 @@ def to_params end end + context "when parsed_data has string keys" do + before do + Her::API.setup url: "https://api.example.com" do |builder| + builder.use Class.new(Faraday::Middleware) { + def on_complete(env) + env[:body] = { + "data" => JSON.parse(env[:body]), + "metadata" => {}, + "errors" => {} + } + end + } + builder.adapter :test do |stub| + stub.get("/users") { [200, {}, [{ "id" => 1, "name" => "Tobias" }].to_json] } + stub.get("/users/1") { [200, {}, { "id" => 1, "name" => "Tobias" }.to_json] } + end + end + + spawn_model "Foo::User" + end + + it "handles collections with string-keyed parsed data" do + users = Foo::User.all + expect(users.length).to eq(1) + expect(users.first.name).to eq("Tobias") + end + + it "handles single resources with string-keyed parsed data" do + user = Foo::User.find(1) + expect(user.name).to eq("Tobias") + end + end + context "when parse_root_in_json set json_api to true" do before do Her::API.setup url: "https://api.example.com" do |builder| From a97912611c53f5eeec1f728308c5f49cb58b3d73 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 22:32:29 -0600 Subject: [PATCH 20/31] Fix after_find callback being called twice Closes: remi/her#517 Remove duplicate run_callbacks :find in Relation#find since instantiate_record already triggers the :find callback. This was causing after_find hooks to execute twice per find. --- lib/her/model/relation.rb | 1 - spec/model/callbacks_spec.rb | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/her/model/relation.rb b/lib/her/model/relation.rb index b6a93b9e..2be23978 100644 --- a/lib/her/model/relation.rb +++ b/lib/her/model/relation.rb @@ -102,7 +102,6 @@ def find(*ids) @parent.request(request_params) do |parsed_data, response| if response.success? resource = @parent.new_from_parsed_data(parsed_data) - resource.run_callbacks :find else return nil end diff --git a/spec/model/callbacks_spec.rb b/spec/model/callbacks_spec.rb index f0f30ac3..29f1efb9 100644 --- a/spec/model/callbacks_spec.rb +++ b/spec/model/callbacks_spec.rb @@ -127,6 +127,24 @@ def alter_name end end + context "callback invocation count" do + before do + spawn_model "User" do + attr_accessor :find_count + after_find :track_find + def track_find + self.find_count ||= 0 + self.find_count += 1 + end + end + end + + it "calls after_find exactly once" do + user = User.find(1) + expect(user.find_count).to eq(1) + end + end + context "when using a block callback" do before do spawn_model "User" do From 81f375b5fe186b009979d86c20fee549df4d823e Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 22:33:27 -0600 Subject: [PATCH 21/31] Add cycle detection to inspect for circular associations Closes: remi/her#406 Use thread-local tracking in inspect to detect and truncate cyclic references with "..." instead of infinitely recursing through bidirectional associations. --- lib/her/model/introspection.rb | 12 +++++++++++- spec/model/introspection_spec.rb | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/lib/her/model/introspection.rb b/lib/her/model/introspection.rb index 34f15926..fe282a17 100644 --- a/lib/her/model/introspection.rb +++ b/lib/her/model/introspection.rb @@ -12,13 +12,23 @@ module Introspection # @user = User.find(1) # p @user # => # def inspect + first = Thread.current[:her_inspect_objects].nil? + Thread.current[:her_inspect_objects] = [] if first + resource_path = begin request_path rescue Her::Errors::PathError => e "" end - "#<#{self.class}(#{resource_path}) #{attributes.keys.map { |k| "#{k}=#{attribute_for_inspect(send(k))}" }.join(" ")}>" + if Thread.current[:her_inspect_objects].include?(object_id) + "#<#{self.class}(#{resource_path}) ...>" + else + Thread.current[:her_inspect_objects] << object_id + "#<#{self.class}(#{resource_path}) #{attributes.keys.map { |k| "#{k}=#{attribute_for_inspect(send(k))}" }.join(" ")}>" + end + ensure + Thread.current[:her_inspect_objects] = nil if first end private diff --git a/spec/model/introspection_spec.rb b/spec/model/introspection_spec.rb index a2037197..12c53a06 100644 --- a/spec/model/introspection_spec.rb +++ b/spec/model/introspection_spec.rb @@ -52,6 +52,35 @@ def password end end + describe "#inspect with cyclic associations" do + before do + Her::API.setup url: "https://api.example.com" do |builder| + builder.use Her::Middleware::FirstLevelParseJSON + builder.adapter :test do |stub| + stub.get("/users/1") { [200, {}, { id: 1, name: "Tobias", comments: [{ id: 2, body: "Hey!", user_id: 1 }] }.to_json] } + end + end + + spawn_model "Foo::User" do + has_many :comments, class_name: "Foo::Comment" + end + spawn_model "Foo::Comment" do + belongs_to :user, class_name: "Foo::User" + end + end + + it "does not infinitely recurse" do + user = Foo::User.find(1) + expect { Timeout.timeout(5) { user.inspect } }.not_to raise_error + end + + it "truncates cyclic references with ..." do + user = Foo::User.find(1) + output = user.inspect + expect(output).to include("Foo::User") + end + end + describe "#inspect with errors in resource path" do it "prints the resource path as “unknown”" do @comment = Foo::Comment.where(project_id: 1).first From d1769a4d46e8888c2821893ea2d1e5f5954bf194 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 22:54:12 -0600 Subject: [PATCH 22/31] Fix has_one/belongs_to returning collection in AMS format Closes: remi/her#212, remi/her#469 In active_model_serializers format, only treat the response as a collection when the parsed data contains the pluralized root key. This prevents has_one and belongs_to associations from being incorrectly parsed as collections, which returned [] instead of the expected single resource. --- lib/her/model/http.rb | 2 +- spec/model/associations_spec.rb | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/her/model/http.rb b/lib/her/model/http.rb index aba9ee36..b8baf103 100644 --- a/lib/her/model/http.rb +++ b/lib/her/model/http.rb @@ -70,7 +70,7 @@ def #{method}(path, params={}) path = build_request_path_from_string_or_symbol(path, params) params = to_params(params) unless #{method.to_sym.inspect} == :get send(:'#{method}_raw', path, params) do |parsed_data, response| - if parsed_data[:data].is_a?(Array) || active_model_serializers_format? || json_api_format? + if parsed_data[:data].is_a?(Array) || json_api_format? || (active_model_serializers_format? && parsed_data[:data].is_a?(Hash) && parsed_data[:data].key?(pluralized_parsed_root_element)) new_collection(parsed_data) else new_from_parsed_data(parsed_data) diff --git a/spec/model/associations_spec.rb b/spec/model/associations_spec.rb index 735cb345..990484d8 100644 --- a/spec/model/associations_spec.rb +++ b/spec/model/associations_spec.rb @@ -665,6 +665,9 @@ builder.adapter :test do |stub| stub.get("/users/1") { [200, {}, { user: { id: 1, name: "Tobias Fünke", comments: [{ id: 2, body: "Tobias, you blow hard!", user_id: 1 }, { id: 3, body: "I wouldn't mind kissing that man between the cheeks, so to speak", user_id: 1 }], role: { id: 1, body: "Admin" }, organization: { id: 1, name: "Bluth Company" }, organization_id: 1 } }.to_json] } stub.get("/users/1/comments") { [200, {}, { comments: [{ id: 4, body: "They're having a FIRESALE?" }] }.to_json] } + stub.get("/users/1/role") { [200, {}, { role: { id: 3, body: "User" } }.to_json] } + stub.get("/users/2") { [200, {}, { user: { id: 2, name: "Lindsay Fünke", organization_id: 1 } }.to_json] } + stub.get("/users/2/role") { [200, {}, { role: { id: 4, body: "Viewer" } }.to_json] } stub.get("/organizations/1") { [200, {}, { organization: { id: 1, name: "Bluth Company Foo" } }.to_json] } end end @@ -698,6 +701,19 @@ expect(user.organization.where(foo_id: 1).name).to eq("Bluth Company Foo") end + it "maps included data through has_one with AMS format" do + expect(user.role).to be_a(Foo::Role) + expect(user.role.id).to eq(1) + expect(user.role.body).to eq("Admin") + end + + it "fetches has_one data not included with AMS format" do + user2 = Foo::User.find(2) + expect(user2.role).to be_a(Foo::Role) + expect(user2.role.id).to eq(4) + expect(user2.role.body).to eq("Viewer") + end + it "includes has_many relationships in params by default" do expect(user_params[:comments]).to be_kind_of(Array) expect(user_params[:comments].length).to eq(2) From 161e3e6644013b7fc157e2314c497be9e3e8fed0 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 22:55:28 -0600 Subject: [PATCH 23/31] Fix has_many create not appending to existing collection Closes: remi/her#286 When creating a resource through a has_many association that already has fetched data, append to the cached collection rather than only checking @parent.attributes. This ensures the new resource is visible immediately without a refetch. --- .../associations/has_many_association.rb | 6 +++-- spec/model/associations_spec.rb | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/her/model/associations/has_many_association.rb b/lib/her/model/associations/has_many_association.rb index c906aac7..fdafce8f 100644 --- a/lib/her/model/associations/has_many_association.rb +++ b/lib/her/model/associations/has_many_association.rb @@ -74,8 +74,10 @@ def create(attributes = {}) resource = build(attributes) if resource.save - @parent.attributes[@name] ||= Her::Collection.new - @parent.attributes[@name] << resource + collection = @cached_result || @parent.attributes[@name] || Her::Collection.new + collection << resource + @cached_result = collection + @parent.attributes[@name] = collection end resource diff --git a/spec/model/associations_spec.rb b/spec/model/associations_spec.rb index 990484d8..76fa2401 100644 --- a/spec/model/associations_spec.rb +++ b/spec/model/associations_spec.rb @@ -940,6 +940,30 @@ def present? end end + context "with #create when collection already has data" do + before do + Her::API.setup url: "https://api.example.com" do |builder| + builder.use Her::Middleware::FirstLevelParseJSON + builder.use Faraday::Request::UrlEncoded + builder.adapter :test do |stub| + stub.get("/users/10") { [200, {}, { id: 10 }.to_json] } + stub.get("/users/10/comments") { [200, {}, [{ id: 1, body: "Existing", user_id: 10 }].to_json] } + stub.post("/comments") { |env| [200, {}, { id: 2, body: Faraday::Utils.parse_query(env[:body])["body"], user_id: 10 }.to_json] } + end + end + + Foo::User.use_api Her::API.default_api + Foo::Comment.use_api Her::API.default_api + end + + it "appends to the existing collection" do + user = Foo::User.find(10) + expect(user.comments.length).to eq(1) + user.comments.create(body: "New!") + expect(user.comments.length).to eq(2) + end + end + context "with #new" do let(:user) { Foo::User.new(name: "vic", comments: [comment]) } From dc5e7e7d1a444bd32b3c0f7316be32ee717bec23 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 22:56:28 -0600 Subject: [PATCH 24/31] Skip send_only_modified_attributes when changes is empty Closes: remi/her#272 When send_only_modified_attributes is enabled and to_params is called without changes (e.g. custom_post actions), don't strip all attributes. Only apply the filter when there are actual tracked changes to slice by. --- lib/her/model/parse.rb | 2 +- spec/model/parse_spec.rb | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/her/model/parse.rb b/lib/her/model/parse.rb index e59a99c1..c7e6e40b 100644 --- a/lib/her/model/parse.rb +++ b/lib/her/model/parse.rb @@ -44,7 +44,7 @@ def to_params(attributes, changes = {}) filtered_attributes.merge!(embedded_params(attributes)) - if her_api.options[:send_only_modified_attributes] + if her_api.options[:send_only_modified_attributes] && changes.any? filtered_attributes.slice! *changes.keys.map(&:to_sym) end diff --git a/spec/model/parse_spec.rb b/spec/model/parse_spec.rb index fd5b8eba..93d2e600 100644 --- a/spec/model/parse_spec.rb +++ b/spec/model/parse_spec.rb @@ -534,6 +534,12 @@ def on_complete(env) user.first_name = "Someone" expect(user.to_params).to eql(user: { first_name: "Someone" }) end + + it "sends all attributes when changes is empty (custom actions)" do + user = Foo::User.find 1 + params = Foo::User.to_params(user.attributes) + expect(params).to eql(user: { id: 1, first_name: "Gooby", last_name: "Pls" }) + end end context 'when passed a non-Her ActiveModel instance' do From 38f728d27b0432bfa7834c5fedfb458aea4da234 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 23:10:51 -0600 Subject: [PATCH 25/31] Remove deprecated methods, stale TODOs, and metaclass pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove DeprecatedMethods module (data/data=/assign_data/has_data?/ get_data/has_relationship?/get_relationship/relationships/her_api) — deprecated for 10+ years with no removal timeline - Remove deprecated options Hash support for custom_get/custom_post etc. (TODO said "remove after January 2020") - Replace (class << self; self; end) with singleton_class - Remove stale TODO comment from has_many#build --- gemfiles/Gemfile.activemodel-6.1.lock | 69 ++++++++++++++++ gemfiles/Gemfile.activemodel-7.0.lock | 79 ++++++++++++++++++ gemfiles/Gemfile.activemodel-7.1.lock | 81 +++++++++++++++++++ gemfiles/Gemfile.activemodel-7.2.lock | 76 +++++++++++++++++ gemfiles/Gemfile.activemodel-8.0.lock | 80 ++++++++++++++++++ him.code-workspace | 10 +++ lib/her/model.rb | 2 - .../associations/has_many_association.rb | 2 - lib/her/model/deprecated_methods.rb | 61 -------------- lib/her/model/http.rb | 12 +-- lib/her/model/orm.rb | 2 +- spec/model/http_spec.rb | 14 ---- spec/model/relation_spec.rb | 1 + 13 files changed, 399 insertions(+), 90 deletions(-) create mode 100644 gemfiles/Gemfile.activemodel-6.1.lock create mode 100644 gemfiles/Gemfile.activemodel-7.0.lock create mode 100644 gemfiles/Gemfile.activemodel-7.1.lock create mode 100644 gemfiles/Gemfile.activemodel-7.2.lock create mode 100644 gemfiles/Gemfile.activemodel-8.0.lock create mode 100644 him.code-workspace delete mode 100644 lib/her/model/deprecated_methods.rb diff --git a/gemfiles/Gemfile.activemodel-6.1.lock b/gemfiles/Gemfile.activemodel-6.1.lock new file mode 100644 index 00000000..35af9680 --- /dev/null +++ b/gemfiles/Gemfile.activemodel-6.1.lock @@ -0,0 +1,69 @@ +PATH + remote: .. + specs: + her (1.1.1) + activemodel (>= 6.1) + faraday (>= 2.0) + +GEM + remote: https://rubygems.org/ + specs: + activemodel (6.1.7.10) + activesupport (= 6.1.7.10) + activesupport (6.1.7.10) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) + concurrent-ruby (1.3.6) + diff-lcs (1.6.2) + drb (2.2.3) + faraday (2.14.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + json (2.19.3) + logger (1.7.0) + minitest (6.0.2) + drb (~> 2.0) + prism (~> 1.5) + net-http (0.9.1) + uri (>= 0.11.1) + prism (1.9.0) + rake (13.3.1) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uri (1.1.1) + zeitwerk (2.7.5) + +PLATFORMS + arm64-darwin-25 + ruby + +DEPENDENCIES + activemodel (~> 6.1.0) + activesupport (~> 6.1.0) + her! + rake (>= 13.0) + rspec (~> 3.5) + +BUNDLED WITH + 2.5.22 diff --git a/gemfiles/Gemfile.activemodel-7.0.lock b/gemfiles/Gemfile.activemodel-7.0.lock new file mode 100644 index 00000000..9e22bbf4 --- /dev/null +++ b/gemfiles/Gemfile.activemodel-7.0.lock @@ -0,0 +1,79 @@ +PATH + remote: .. + specs: + her (1.1.1) + activemodel (>= 6.1) + faraday (>= 2.0) + +GEM + remote: https://rubygems.org/ + specs: + activemodel (7.0.10) + activesupport (= 7.0.10) + activesupport (7.0.10) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + mutex_m + securerandom (>= 0.3) + tzinfo (~> 2.0) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.0) + concurrent-ruby (1.3.6) + diff-lcs (1.6.2) + drb (2.2.3) + faraday (2.14.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + json (2.19.3) + logger (1.7.0) + minitest (6.0.2) + drb (~> 2.0) + prism (~> 1.5) + mutex_m (0.3.0) + net-http (0.9.1) + uri (>= 0.11.1) + prism (1.9.0) + rake (13.3.1) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + securerandom (0.4.1) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uri (1.1.1) + +PLATFORMS + arm64-darwin-25 + ruby + +DEPENDENCIES + activemodel (~> 7.0.0) + activesupport (~> 7.0.0) + her! + rake (>= 13.0) + rspec (~> 3.5) + +BUNDLED WITH + 2.5.22 diff --git a/gemfiles/Gemfile.activemodel-7.1.lock b/gemfiles/Gemfile.activemodel-7.1.lock new file mode 100644 index 00000000..c735a779 --- /dev/null +++ b/gemfiles/Gemfile.activemodel-7.1.lock @@ -0,0 +1,81 @@ +PATH + remote: .. + specs: + her (1.1.1) + activemodel (>= 6.1) + faraday (>= 2.0) + +GEM + remote: https://rubygems.org/ + specs: + activemodel (7.1.6) + activesupport (= 7.1.6) + activesupport (7.1.6) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + mutex_m + securerandom (>= 0.3) + tzinfo (~> 2.0) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.0) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + diff-lcs (1.6.2) + drb (2.2.3) + faraday (2.14.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + json (2.19.3) + logger (1.7.0) + minitest (6.0.2) + drb (~> 2.0) + prism (~> 1.5) + mutex_m (0.3.0) + net-http (0.9.1) + uri (>= 0.11.1) + prism (1.9.0) + rake (13.3.1) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + securerandom (0.4.1) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uri (1.1.1) + +PLATFORMS + arm64-darwin-25 + ruby + +DEPENDENCIES + activemodel (~> 7.1.0) + activesupport (~> 7.1.0) + her! + rake (>= 13.0) + rspec (~> 3.5) + +BUNDLED WITH + 2.5.22 diff --git a/gemfiles/Gemfile.activemodel-7.2.lock b/gemfiles/Gemfile.activemodel-7.2.lock new file mode 100644 index 00000000..d89c2ba1 --- /dev/null +++ b/gemfiles/Gemfile.activemodel-7.2.lock @@ -0,0 +1,76 @@ +PATH + remote: .. + specs: + her (1.1.1) + activemodel (>= 6.1) + faraday (>= 2.0) + +GEM + remote: https://rubygems.org/ + specs: + activemodel (7.2.3.1) + activesupport (= 7.2.3.1) + activesupport (7.2.3.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1, < 6) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.0) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + diff-lcs (1.6.2) + drb (2.2.3) + faraday (2.14.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + json (2.19.3) + logger (1.7.0) + minitest (5.27.0) + net-http (0.9.1) + uri (>= 0.11.1) + rake (13.3.1) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + securerandom (0.4.1) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uri (1.1.1) + +PLATFORMS + arm64-darwin-25 + ruby + +DEPENDENCIES + activemodel (~> 7.2.0) + activesupport (~> 7.2.0) + her! + rake (>= 13.0) + rspec (~> 3.5) + +BUNDLED WITH + 2.5.22 diff --git a/gemfiles/Gemfile.activemodel-8.0.lock b/gemfiles/Gemfile.activemodel-8.0.lock new file mode 100644 index 00000000..14f94078 --- /dev/null +++ b/gemfiles/Gemfile.activemodel-8.0.lock @@ -0,0 +1,80 @@ +PATH + remote: .. + specs: + her (1.1.1) + activemodel (>= 6.1) + faraday (>= 2.0) + +GEM + remote: https://rubygems.org/ + specs: + activemodel (8.0.5) + activesupport (= 8.0.5) + activesupport (8.0.5) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.0) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + diff-lcs (1.6.2) + drb (2.2.3) + faraday (2.14.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + json (2.19.3) + logger (1.7.0) + minitest (6.0.2) + drb (~> 2.0) + prism (~> 1.5) + net-http (0.9.1) + uri (>= 0.11.1) + prism (1.9.0) + rake (13.3.1) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + securerandom (0.4.1) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uri (1.1.1) + +PLATFORMS + arm64-darwin-25 + ruby + +DEPENDENCIES + activemodel (~> 8.0.0) + activesupport (~> 8.0.0) + her! + rake (>= 13.0) + rspec (~> 3.5) + +BUNDLED WITH + 2.5.22 diff --git a/him.code-workspace b/him.code-workspace new file mode 100644 index 00000000..f237f4f9 --- /dev/null +++ b/him.code-workspace @@ -0,0 +1,10 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "window.title": "${alterminalBell}🤴🏼 Him" + } +} diff --git a/lib/her/model.rb b/lib/her/model.rb index f026af66..cbb62d65 100644 --- a/lib/her/model.rb +++ b/lib/her/model.rb @@ -1,5 +1,4 @@ require "her/model/base" -require "her/model/deprecated_methods" require "her/model/http" require "her/model/attributes" require "her/model/relation" @@ -27,7 +26,6 @@ module Model # Her modules include Her::Model::Base - include Her::Model::DeprecatedMethods include Her::Model::Attributes include Her::Model::ORM include Her::Model::HTTP diff --git a/lib/her/model/associations/has_many_association.rb b/lib/her/model/associations/has_many_association.rb index fdafce8f..f154dca9 100644 --- a/lib/her/model/associations/has_many_association.rb +++ b/lib/her/model/associations/has_many_association.rb @@ -49,8 +49,6 @@ def self.parse(association, klass, data) # user = User.find(1) # new_comment = user.comments.build(:body => "Hello!") # new_comment # => # - # TODO: This only merges the id of the parents, handle the case - # where this is more deeply nested def build(attributes = {}) @klass.build(attributes.merge(foreign_key => @parent.id)) end diff --git a/lib/her/model/deprecated_methods.rb b/lib/her/model/deprecated_methods.rb deleted file mode 100644 index 61b500c5..00000000 --- a/lib/her/model/deprecated_methods.rb +++ /dev/null @@ -1,61 +0,0 @@ -module Her - module Model - # @private - module DeprecatedMethods - extend ActiveSupport::Concern - - def self.deprecate!(old, new, object, *args) - line = begin - raise StandardError - rescue StandardError => e - e.backtrace[2] - end - - warn "#{line} - The `#{old}` method is deprecated and may be removed soon. Please update your code with `#{new}` instead." - object.send(new, *args) - end - - def data(*args) - Her::Model::DeprecatedMethods.deprecate! :data, :attributes, self, *args - end - - def data=(*args) - Her::Model::DeprecatedMethods.deprecate! :data=, :attributes=, self, *args - end - - def update_attributes(*args) - Her::Model::DeprecatedMethods.deprecate! :update_attributes, :assign_attributes, self, *args - end - - def assign_data(*args) - Her::Model::DeprecatedMethods.deprecate! :assign_data, :assign_attributes, self, *args - end - - def has_data?(*args) - Her::Model::DeprecatedMethods.deprecate! :has_data?, :has_attribute?, self, *args - end - - def get_data(*args) - Her::Model::DeprecatedMethods.deprecate! :get_data, :get_attribute, self, *args - end - - module ClassMethods - def has_relationship?(*args) - Her::Model::DeprecatedMethods.deprecate! :has_relationship?, :has_association?, self, *args - end - - def get_relationship(*args) - Her::Model::DeprecatedMethods.deprecate! :get_relationship, :get_association, self, *args - end - - def relationships(*args) - Her::Model::DeprecatedMethods.deprecate! :relationships, :associations, self, *args - end - - def her_api(*args) - Her::Model::DeprecatedMethods.deprecate! :her_api, :use_api, self, *args - end - end - end - end -end diff --git a/lib/her/model/http.rb b/lib/her/model/http.rb index b8baf103..319e5479 100644 --- a/lib/her/model/http.rb +++ b/lib/her/model/http.rb @@ -98,17 +98,9 @@ def #{method}_resource(path, params={}) end def custom_#{method}(*paths) - metaclass = (class << self; self; end) - - # TODO: Remove this check after January 2020 - if paths.last.is_a?(Hash) - warn("[DEPRECATION] options for custom request methods are deprecated and will be removed on or after January 2020.") - paths.pop - end - paths.each do |path| - metaclass.send(:define_method, path) do |*params| - params = params.first || Hash.new + singleton_class.send(:define_method, path) do |*params| + params = params.first || {} send(#{method.to_sym.inspect}, path, params) end end diff --git a/lib/her/model/orm.rb b/lib/her/model/orm.rb index 8ff30280..b588037e 100644 --- a/lib/her/model/orm.rb +++ b/lib/her/model/orm.rb @@ -188,7 +188,7 @@ module ClassMethods # User.page(2).all # Called via GET "/users?page=2" def scope(name, code) # Add the scope method to the class - (class << self; self end).send(:define_method, name) do |*args| + singleton_class.send(:define_method, name) do |*args| instance_exec(*args, &code) end diff --git a/spec/model/http_spec.rb b/spec/model/http_spec.rb index e88da45b..cb709d0a 100644 --- a/spec/model/http_spec.rb +++ b/spec/model/http_spec.rb @@ -183,19 +183,5 @@ end end - context "with options" do - before do - allow(Foo::User).to receive(:warn) - Foo::User.custom_get :popular, foo: "bar" - end - - it "issues DEPRECATION warning" do - expect(Foo::User).to have_received(:warn).with("[DEPRECATION] options for custom request methods are deprecated and will be removed on or after January 2020.") - end - - it "makes HTTP request" do - expect(Foo::User.popular.length).to be 2 - end - end end end diff --git a/spec/model/relation_spec.rb b/spec/model/relation_spec.rb index bfc387e5..617bba38 100644 --- a/spec/model/relation_spec.rb +++ b/spec/model/relation_spec.rb @@ -179,6 +179,7 @@ class User < Foo::Model; end expect(Foo::User.scoped.baz.params[:where]).to eq(6) expect(Bar::User.scoped.baz.params[:where]).to eq(7) end + end describe :default_scope do From 630c1eec663f64c5b609add651de670d737891ba Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 23:14:23 -0600 Subject: [PATCH 26/31] Update .gitignore for gemfile lockfiles and IDE files --- .gitignore | 2 + gemfiles/Gemfile.activemodel-6.1.lock | 69 ----------------------- gemfiles/Gemfile.activemodel-7.0.lock | 79 -------------------------- gemfiles/Gemfile.activemodel-7.1.lock | 81 --------------------------- gemfiles/Gemfile.activemodel-7.2.lock | 76 ------------------------- gemfiles/Gemfile.activemodel-8.0.lock | 80 -------------------------- him.code-workspace | 10 ---- 7 files changed, 2 insertions(+), 395 deletions(-) delete mode 100644 gemfiles/Gemfile.activemodel-6.1.lock delete mode 100644 gemfiles/Gemfile.activemodel-7.0.lock delete mode 100644 gemfiles/Gemfile.activemodel-7.1.lock delete mode 100644 gemfiles/Gemfile.activemodel-7.2.lock delete mode 100644 gemfiles/Gemfile.activemodel-8.0.lock delete mode 100644 him.code-workspace diff --git a/.gitignore b/.gitignore index f72c9df9..baf083d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /Gemfile.lock +gemfiles/*.lock /pkg /tmp /coverage +*.code-workspace diff --git a/gemfiles/Gemfile.activemodel-6.1.lock b/gemfiles/Gemfile.activemodel-6.1.lock deleted file mode 100644 index 35af9680..00000000 --- a/gemfiles/Gemfile.activemodel-6.1.lock +++ /dev/null @@ -1,69 +0,0 @@ -PATH - remote: .. - specs: - her (1.1.1) - activemodel (>= 6.1) - faraday (>= 2.0) - -GEM - remote: https://rubygems.org/ - specs: - activemodel (6.1.7.10) - activesupport (= 6.1.7.10) - activesupport (6.1.7.10) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 1.6, < 2) - minitest (>= 5.1) - tzinfo (~> 2.0) - zeitwerk (~> 2.3) - concurrent-ruby (1.3.6) - diff-lcs (1.6.2) - drb (2.2.3) - faraday (2.14.1) - faraday-net_http (>= 2.0, < 3.5) - json - logger - faraday-net_http (3.4.2) - net-http (~> 0.5) - i18n (1.14.8) - concurrent-ruby (~> 1.0) - json (2.19.3) - logger (1.7.0) - minitest (6.0.2) - drb (~> 2.0) - prism (~> 1.5) - net-http (0.9.1) - uri (>= 0.11.1) - prism (1.9.0) - rake (13.3.1) - rspec (3.13.2) - rspec-core (~> 3.13.0) - rspec-expectations (~> 3.13.0) - rspec-mocks (~> 3.13.0) - rspec-core (3.13.6) - rspec-support (~> 3.13.0) - rspec-expectations (3.13.5) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-mocks (3.13.8) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-support (3.13.7) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - uri (1.1.1) - zeitwerk (2.7.5) - -PLATFORMS - arm64-darwin-25 - ruby - -DEPENDENCIES - activemodel (~> 6.1.0) - activesupport (~> 6.1.0) - her! - rake (>= 13.0) - rspec (~> 3.5) - -BUNDLED WITH - 2.5.22 diff --git a/gemfiles/Gemfile.activemodel-7.0.lock b/gemfiles/Gemfile.activemodel-7.0.lock deleted file mode 100644 index 9e22bbf4..00000000 --- a/gemfiles/Gemfile.activemodel-7.0.lock +++ /dev/null @@ -1,79 +0,0 @@ -PATH - remote: .. - specs: - her (1.1.1) - activemodel (>= 6.1) - faraday (>= 2.0) - -GEM - remote: https://rubygems.org/ - specs: - activemodel (7.0.10) - activesupport (= 7.0.10) - activesupport (7.0.10) - base64 - benchmark (>= 0.3) - bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) - drb - i18n (>= 1.6, < 2) - logger (>= 1.4.2) - minitest (>= 5.1) - mutex_m - securerandom (>= 0.3) - tzinfo (~> 2.0) - base64 (0.3.0) - benchmark (0.5.0) - bigdecimal (4.1.0) - concurrent-ruby (1.3.6) - diff-lcs (1.6.2) - drb (2.2.3) - faraday (2.14.1) - faraday-net_http (>= 2.0, < 3.5) - json - logger - faraday-net_http (3.4.2) - net-http (~> 0.5) - i18n (1.14.8) - concurrent-ruby (~> 1.0) - json (2.19.3) - logger (1.7.0) - minitest (6.0.2) - drb (~> 2.0) - prism (~> 1.5) - mutex_m (0.3.0) - net-http (0.9.1) - uri (>= 0.11.1) - prism (1.9.0) - rake (13.3.1) - rspec (3.13.2) - rspec-core (~> 3.13.0) - rspec-expectations (~> 3.13.0) - rspec-mocks (~> 3.13.0) - rspec-core (3.13.6) - rspec-support (~> 3.13.0) - rspec-expectations (3.13.5) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-mocks (3.13.8) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-support (3.13.7) - securerandom (0.4.1) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - uri (1.1.1) - -PLATFORMS - arm64-darwin-25 - ruby - -DEPENDENCIES - activemodel (~> 7.0.0) - activesupport (~> 7.0.0) - her! - rake (>= 13.0) - rspec (~> 3.5) - -BUNDLED WITH - 2.5.22 diff --git a/gemfiles/Gemfile.activemodel-7.1.lock b/gemfiles/Gemfile.activemodel-7.1.lock deleted file mode 100644 index c735a779..00000000 --- a/gemfiles/Gemfile.activemodel-7.1.lock +++ /dev/null @@ -1,81 +0,0 @@ -PATH - remote: .. - specs: - her (1.1.1) - activemodel (>= 6.1) - faraday (>= 2.0) - -GEM - remote: https://rubygems.org/ - specs: - activemodel (7.1.6) - activesupport (= 7.1.6) - activesupport (7.1.6) - base64 - benchmark (>= 0.3) - bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) - connection_pool (>= 2.2.5) - drb - i18n (>= 1.6, < 2) - logger (>= 1.4.2) - minitest (>= 5.1) - mutex_m - securerandom (>= 0.3) - tzinfo (~> 2.0) - base64 (0.3.0) - benchmark (0.5.0) - bigdecimal (4.1.0) - concurrent-ruby (1.3.6) - connection_pool (3.0.2) - diff-lcs (1.6.2) - drb (2.2.3) - faraday (2.14.1) - faraday-net_http (>= 2.0, < 3.5) - json - logger - faraday-net_http (3.4.2) - net-http (~> 0.5) - i18n (1.14.8) - concurrent-ruby (~> 1.0) - json (2.19.3) - logger (1.7.0) - minitest (6.0.2) - drb (~> 2.0) - prism (~> 1.5) - mutex_m (0.3.0) - net-http (0.9.1) - uri (>= 0.11.1) - prism (1.9.0) - rake (13.3.1) - rspec (3.13.2) - rspec-core (~> 3.13.0) - rspec-expectations (~> 3.13.0) - rspec-mocks (~> 3.13.0) - rspec-core (3.13.6) - rspec-support (~> 3.13.0) - rspec-expectations (3.13.5) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-mocks (3.13.8) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-support (3.13.7) - securerandom (0.4.1) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - uri (1.1.1) - -PLATFORMS - arm64-darwin-25 - ruby - -DEPENDENCIES - activemodel (~> 7.1.0) - activesupport (~> 7.1.0) - her! - rake (>= 13.0) - rspec (~> 3.5) - -BUNDLED WITH - 2.5.22 diff --git a/gemfiles/Gemfile.activemodel-7.2.lock b/gemfiles/Gemfile.activemodel-7.2.lock deleted file mode 100644 index d89c2ba1..00000000 --- a/gemfiles/Gemfile.activemodel-7.2.lock +++ /dev/null @@ -1,76 +0,0 @@ -PATH - remote: .. - specs: - her (1.1.1) - activemodel (>= 6.1) - faraday (>= 2.0) - -GEM - remote: https://rubygems.org/ - specs: - activemodel (7.2.3.1) - activesupport (= 7.2.3.1) - activesupport (7.2.3.1) - base64 - benchmark (>= 0.3) - bigdecimal - concurrent-ruby (~> 1.0, >= 1.3.1) - connection_pool (>= 2.2.5) - drb - i18n (>= 1.6, < 2) - logger (>= 1.4.2) - minitest (>= 5.1, < 6) - securerandom (>= 0.3) - tzinfo (~> 2.0, >= 2.0.5) - base64 (0.3.0) - benchmark (0.5.0) - bigdecimal (4.1.0) - concurrent-ruby (1.3.6) - connection_pool (3.0.2) - diff-lcs (1.6.2) - drb (2.2.3) - faraday (2.14.1) - faraday-net_http (>= 2.0, < 3.5) - json - logger - faraday-net_http (3.4.2) - net-http (~> 0.5) - i18n (1.14.8) - concurrent-ruby (~> 1.0) - json (2.19.3) - logger (1.7.0) - minitest (5.27.0) - net-http (0.9.1) - uri (>= 0.11.1) - rake (13.3.1) - rspec (3.13.2) - rspec-core (~> 3.13.0) - rspec-expectations (~> 3.13.0) - rspec-mocks (~> 3.13.0) - rspec-core (3.13.6) - rspec-support (~> 3.13.0) - rspec-expectations (3.13.5) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-mocks (3.13.8) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-support (3.13.7) - securerandom (0.4.1) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - uri (1.1.1) - -PLATFORMS - arm64-darwin-25 - ruby - -DEPENDENCIES - activemodel (~> 7.2.0) - activesupport (~> 7.2.0) - her! - rake (>= 13.0) - rspec (~> 3.5) - -BUNDLED WITH - 2.5.22 diff --git a/gemfiles/Gemfile.activemodel-8.0.lock b/gemfiles/Gemfile.activemodel-8.0.lock deleted file mode 100644 index 14f94078..00000000 --- a/gemfiles/Gemfile.activemodel-8.0.lock +++ /dev/null @@ -1,80 +0,0 @@ -PATH - remote: .. - specs: - her (1.1.1) - activemodel (>= 6.1) - faraday (>= 2.0) - -GEM - remote: https://rubygems.org/ - specs: - activemodel (8.0.5) - activesupport (= 8.0.5) - activesupport (8.0.5) - base64 - benchmark (>= 0.3) - bigdecimal - concurrent-ruby (~> 1.0, >= 1.3.1) - connection_pool (>= 2.2.5) - drb - i18n (>= 1.6, < 2) - logger (>= 1.4.2) - minitest (>= 5.1) - securerandom (>= 0.3) - tzinfo (~> 2.0, >= 2.0.5) - uri (>= 0.13.1) - base64 (0.3.0) - benchmark (0.5.0) - bigdecimal (4.1.0) - concurrent-ruby (1.3.6) - connection_pool (3.0.2) - diff-lcs (1.6.2) - drb (2.2.3) - faraday (2.14.1) - faraday-net_http (>= 2.0, < 3.5) - json - logger - faraday-net_http (3.4.2) - net-http (~> 0.5) - i18n (1.14.8) - concurrent-ruby (~> 1.0) - json (2.19.3) - logger (1.7.0) - minitest (6.0.2) - drb (~> 2.0) - prism (~> 1.5) - net-http (0.9.1) - uri (>= 0.11.1) - prism (1.9.0) - rake (13.3.1) - rspec (3.13.2) - rspec-core (~> 3.13.0) - rspec-expectations (~> 3.13.0) - rspec-mocks (~> 3.13.0) - rspec-core (3.13.6) - rspec-support (~> 3.13.0) - rspec-expectations (3.13.5) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-mocks (3.13.8) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-support (3.13.7) - securerandom (0.4.1) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - uri (1.1.1) - -PLATFORMS - arm64-darwin-25 - ruby - -DEPENDENCIES - activemodel (~> 8.0.0) - activesupport (~> 8.0.0) - her! - rake (>= 13.0) - rspec (~> 3.5) - -BUNDLED WITH - 2.5.22 diff --git a/him.code-workspace b/him.code-workspace deleted file mode 100644 index f237f4f9..00000000 --- a/him.code-workspace +++ /dev/null @@ -1,10 +0,0 @@ -{ - "folders": [ - { - "path": "." - } - ], - "settings": { - "window.title": "${alterminalBell}🤴🏼 Him" - } -} From 4048656facc312ba8a29e6e054cf2eac8b750c04 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 23:22:38 -0600 Subject: [PATCH 27/31] Append built resources to has_many collection Closes: remi/her#287 has_many#build now appends the new resource to the cached collection so it's immediately visible via the association. If create fails (save returns false), the resource is removed from the collection. --- .../associations/has_many_association.rb | 18 ++++++++++----- spec/model/associations_spec.rb | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/lib/her/model/associations/has_many_association.rb b/lib/her/model/associations/has_many_association.rb index f154dca9..fe4698af 100644 --- a/lib/her/model/associations/has_many_association.rb +++ b/lib/her/model/associations/has_many_association.rb @@ -50,7 +50,14 @@ def self.parse(association, klass, data) # new_comment = user.comments.build(:body => "Hello!") # new_comment # => # def build(attributes = {}) - @klass.build(attributes.merge(foreign_key => @parent.id)) + resource = @klass.build(attributes.merge(foreign_key => @parent.id)) + + collection = @cached_result || @parent.attributes[@name] || Her::Collection.new + collection << resource + @cached_result = collection + @parent.attributes[@name] = collection + + resource end # Create a new object, save it and add it to the associated collection @@ -71,11 +78,10 @@ def build(attributes = {}) def create(attributes = {}) resource = build(attributes) - if resource.save - collection = @cached_result || @parent.attributes[@name] || Her::Collection.new - collection << resource - @cached_result = collection - @parent.attributes[@name] = collection + unless resource.save + # Remove the built resource from the collection if save failed + @cached_result&.delete(resource) + @parent.attributes[@name]&.delete(resource) end resource diff --git a/spec/model/associations_spec.rb b/spec/model/associations_spec.rb index 76fa2401..8018c6a4 100644 --- a/spec/model/associations_spec.rb +++ b/spec/model/associations_spec.rb @@ -940,6 +940,29 @@ def present? end end + context "with #build appending to existing collection" do + before do + Her::API.setup url: "https://api.example.com" do |builder| + builder.use Her::Middleware::FirstLevelParseJSON + builder.use Faraday::Request::UrlEncoded + builder.adapter :test do |stub| + stub.get("/users/1") { [200, {}, { id: 1 }.to_json] } + stub.get("/users/1/comments") { [200, {}, [{ id: 1, body: "Existing", user_id: 1 }].to_json] } + end + end + + Foo::User.use_api Her::API.default_api + Foo::Comment.use_api Her::API.default_api + end + + it "appends built resource to the existing collection" do + user = Foo::User.find(1) + expect(user.comments.length).to eq(1) + user.comments.build(body: "New!") + expect(user.comments.length).to eq(2) + end + end + context "with #create when collection already has data" do before do Her::API.setup url: "https://api.example.com" do |builder| From 54b602847469865f873d60888b92bda28c162a15 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 23:23:33 -0600 Subject: [PATCH 28/31] Preserve Collection type through Array methods Closes: remi/her#278 Override select, reject, map, and other Array methods on Her::Collection to return Collection instances that retain metadata and errors, instead of plain Arrays that lose this context. --- lib/her/collection.rb | 8 ++++++++ spec/collection_spec.rb | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/lib/her/collection.rb b/lib/her/collection.rb index a5d5cf9f..a33fe704 100644 --- a/lib/her/collection.rb +++ b/lib/her/collection.rb @@ -9,5 +9,13 @@ def initialize(items = [], metadata = {}, errors = {}) @metadata = metadata @errors = errors end + + %i[select reject collect map compact flatten uniq reverse + sort sort_by sample shuffle slice drop take first last].each do |method| + define_method(method) do |*args, &block| + result = super(*args, &block) + result.is_a?(Array) ? self.class.new(result, @metadata, @errors) : result + end + end end end diff --git a/spec/collection_spec.rb b/spec/collection_spec.rb index ecfe0748..05d5a666 100644 --- a/spec/collection_spec.rb +++ b/spec/collection_spec.rb @@ -38,4 +38,33 @@ end end end + + describe "Array methods preserve Collection type" do + subject { Her::Collection.new(items, metadata, errors) } + + it "returns a Collection from #select" do + result = subject.select(&:odd?) + expect(result).to be_a(Her::Collection) + expect(result).to eq([1, 3]) + expect(result.metadata).to eq(name: "Testname") + end + + it "returns a Collection from #reject" do + result = subject.reject(&:odd?) + expect(result).to be_a(Her::Collection) + expect(result).to eq([2, 4]) + end + + it "returns a Collection from #map" do + result = subject.map { |x| x * 2 } + expect(result).to be_a(Her::Collection) + expect(result).to eq([2, 4, 6, 8]) + end + + it "returns a Collection from #first with count" do + result = subject.first(2) + expect(result).to be_a(Her::Collection) + expect(result).to eq([1, 2]) + end + end end From edda3f211d56414534c650f3fd892e225269e6e0 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 23:24:20 -0600 Subject: [PATCH 29/31] Unwrap root key in collections when parse_root_in_json is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes: remi/her#380, remi/her#331 extract_array now checks parse_root_in_json? in addition to the AMS and JSON API format flags, so APIs that wrap collections in a root key (e.g. {"users": [...]}) work correctly with just parse_root_in_json true — no need to also set AMS format. --- lib/her/model/parse.rb | 7 ++++--- spec/model/parse_spec.rb | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/lib/her/model/parse.rb b/lib/her/model/parse.rb index c7e6e40b..d60bb0d4 100644 --- a/lib/her/model/parse.rb +++ b/lib/her/model/parse.rb @@ -173,10 +173,11 @@ def included_root_element # # @private def extract_array(request_data) - if request_data[:data].is_a?(Hash) && (active_model_serializers_format? || json_api_format?) - request_data[:data][pluralized_parsed_root_element] + data = request_data[:data] + if data.is_a?(Hash) && (parse_root_in_json? || active_model_serializers_format? || json_api_format?) + data[pluralized_parsed_root_element] else - request_data[:data] + data end end diff --git a/spec/model/parse_spec.rb b/spec/model/parse_spec.rb index 93d2e600..8d81555d 100644 --- a/spec/model/parse_spec.rb +++ b/spec/model/parse_spec.rb @@ -358,6 +358,33 @@ def to_params end end + context "when parse_root_in_json is true without AMS format" do + before do + Her::API.setup url: "https://api.example.com" do |builder| + builder.use Her::Middleware::FirstLevelParseJSON + builder.adapter :test do |stub| + stub.get("/users") { [200, {}, { users: [{ id: 1, name: "Tobias" }] }.to_json] } + stub.get("/users/1") { [200, {}, { user: { id: 1, name: "Tobias" } }.to_json] } + end + end + + spawn_model "Foo::User" do + parse_root_in_json true + end + end + + it "unwraps collection root key" do + users = Foo::User.all + expect(users.length).to eq(1) + expect(users.first.name).to eq("Tobias") + end + + it "unwraps single resource root key" do + user = Foo::User.find(1) + expect(user.name).to eq("Tobias") + end + end + context "when associations reference each other" do before do Her::API.setup url: "https://api.example.com" do |builder| From d5083ed9217f47ec318304758918889d5cf5bce5 Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Mon, 30 Mar 2026 23:54:19 -0600 Subject: [PATCH 30/31] Update README and LICENSE for modernization - Remove dead badges (Travis, Gemnasium, CodeClimate, Gitter) - Update Faraday examples for 2.x (adapter syntax, middleware class) - Replace MultiJson with JSON in custom parser example - Update before_filter to before_action - Update BasicAuthentication to Faraday 2.x request syntax - Remove deprecated update_attributes from examples - Remove dead links (her-rb.org, rdoc.info, her-example repo) - Remove stale project list, history, and contributors sections - Update copyright years and add contributor copyright --- LICENSE | 2 +- README.md | 109 +++++++++++++----------------------------------------- 2 files changed, 26 insertions(+), 85 deletions(-) diff --git a/LICENSE b/LICENSE index 84c3209c..bdc8be0e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2012-2015 Rémi Prévost +Copyright (c) 2012-2026 Rémi Prévost 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: diff --git a/README.md b/README.md index 798a912c..21d60601 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,10 @@ -

- - Her - -
- Her is an ORM (Object Relational Mapper) that maps REST resources to Ruby objects.
It is designed to build applications that are powered by a RESTful API instead of a database. -

- - - - - Gitter chat -

+# Her + +Her is an ORM (Object Relational Mapper) that maps REST resources to Ruby objects. +It is designed to build applications that are powered by a RESTful API instead of a database. + +**Requirements:** Ruby >= 3.1, ActiveModel >= 6.1, Faraday >= 2.0 --- @@ -27,8 +20,6 @@ That’s it! ## Usage -_For a complete reference of all the methods you can use, check out [the documentation](http://rdoc.info/github/remiprev/her)._ - First, you have to define which API your models will be bound to. For example, with Rails, you would create a new `config/initializers/her.rb` file with these lines: ```ruby @@ -41,7 +32,7 @@ Her::API.setup url: "https://api.example.com" do |c| c.use Her::Middleware::DefaultParseJSON # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end ``` @@ -91,10 +82,6 @@ user.fullname = "Lindsay Fünke" # OR user.assign_attributes(fullname: "Lindsay user.save # returns false if it fails, errors in user.response_errors array # PUT "/users/1" with `fullname=Lindsay+Fünke` -user.update_attributes(fullname: "Maeby Fünke") -# PUT "/users/1" with `fullname=Maeby+Fünke` - -# => PUT /users/1 { "id": 1, "name": "new new name" } # Update a resource without fetching it User.save_existing(1, fullname: "Lindsay Fünke") # PUT "/users/1" with `fullname=Lindsay+Fünke` @@ -124,8 +111,6 @@ user.save! # raises Her::Errors::ResourceInvalid if it fails # POST "/users" with `fullname=Maeby+Fünke` ``` -You can look into the [`her-example`](https://github.com/remiprev/her-example) repository for a sample application using Her. - ## Middleware Since Her relies on [Faraday](https://github.com/lostisland/faraday) to send HTTP requests, you can choose the middleware used to handle requests and responses. Using the block in the `setup` call, you have access to Faraday’s `connection` object and are able to customize the middleware stack used on each request and response. @@ -139,7 +124,7 @@ For example, to add a token header to your API requests in a Rails application, ```ruby # app/controllers/application_controller.rb class ApplicationController < ActionController::Base - before_filter :set_user_api_token + before_action :set_user_api_token protected def set_user_api_token @@ -167,27 +152,28 @@ Her::API.setup url: "https://api.example.com" do |c| c.use Her::Middleware::DefaultParseJSON # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end ``` Now, each HTTP request made by Her will have the `X-API-Token` header. -### Basic Http Authentication -Her can use basic http auth by adding a line to your initializer +### Basic HTTP Authentication + +Her can use basic HTTP auth by adding a line to your initializer: ```ruby # config/initializers/her.rb Her::API.setup url: "https://api.example.com" do |c| # Request - c.use Faraday::Request::BasicAuthentication, 'myusername', 'mypassword' + c.request :authorization, :basic, 'myusername', 'mypassword' c.use Faraday::Request::UrlEncoded # Response c.use Her::Middleware::DefaultParseJSON # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end ``` @@ -222,7 +208,7 @@ Her::API.setup url: "https://api.twitter.com/1/" do |c| c.use Her::Middleware::DefaultParseJSON # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end class Tweet @@ -258,9 +244,9 @@ Also, you can define your own parsing method using a response middleware. The mi # "errors": [] # } # -class MyCustomParser < Faraday::Response::Middleware +class MyCustomParser < Faraday::Middleware def on_complete(env) - json = MultiJson.load(env[:body], symbolize_keys: true) + json = JSON.parse(env[:body], symbolize_names: true) env[:body] = { data: json[:result], errors: json[:errors], @@ -274,7 +260,7 @@ Her::API.setup url: "https://api.example.com" do |c| c.use MyCustomParser # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end ``` @@ -301,7 +287,7 @@ Her::API.setup url: "https://api.example.com" do |c| c.use Her::Middleware::DefaultParseJSON # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end class User @@ -662,7 +648,7 @@ Her::API.setup url: 'https://my_awesome_json_api_service' do |c| c.use Her::Middleware::JsonApiParser # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end ``` @@ -869,7 +855,7 @@ MY_API.setup url: "https://my-api.example.com" do |c| c.use Her::Middleware::DefaultParseJSON # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end OTHER_API = Her::API.new @@ -878,7 +864,7 @@ OTHER_API.setup url: "https://other-api.example.com" do |c| c.use Her::Middleware::DefaultParseJSON # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end ``` @@ -913,7 +899,7 @@ Her::API.setup url: "https://api.example.com", ssl: ssl_options do |c| c.use Her::Middleware::DefaultParseJSON # Adapter - c.use Faraday::Adapter::NetHttp + c.adapter :net_http end ``` @@ -1009,57 +995,12 @@ end See the [UPGRADE.md](https://github.com/remiprev/her/blob/master/UPGRADE.md) for backward compatibility issues. -## Her IRL - -Most projects I know that use Her are internal or private projects but here’s a list of public ones: - -* [tumbz](https://github.com/remiprev/tumbz) -* [zoho-ruby](https://github.com/errorstudio/zoho-ruby) -* [crowdher](https://github.com/simonprev/crowdher) -* [vodka](https://github.com/magnolia-fan/vodka) -* [webistrano_cli](https://github.com/chytreg/webistrano_cli) -* [ASMALLWORLD](https://www.asmallworld.com) - -## History - -I told myself a few months ago that it would be great to build a gem to replace Rails’ [ActiveResource](http://api.rubyonrails.org/classes/ActiveResource/Base.html) since it was barely maintained (and now removed from Rails 4.0), lacking features and hard to extend/customize. I had built a few of these REST-powered ORMs for client projects before but I decided I wanted to write one for myself that I could release as an open-source project. - -Most of Her’s core concepts were written on a Saturday morning of April 2012 ([first commit](https://github.com/remiprev/her/commit/689d8e88916dc2ad258e69a2a91a283f061cbef2) at 7am!). - -## Maintainers -The gem is currently maintained by [@zacharywelch](https://github.com/zacharywelch) and [@edtjones](https://github.com/edtjones). - ## Contribute -Yes please! Feel free to contribute and submit issues/pull requests [on GitHub](https://github.com/remiprev/her/issues). There’s no such thing as a bad pull request — even if it’s for a typo, a small improvement to the code or the documentation! +Feel free to contribute and submit issues/pull requests [on GitHub](https://github.com/remiprev/her/issues). See [CONTRIBUTING.md](https://github.com/remiprev/her/blob/master/CONTRIBUTING.md) for best practices. -### Contributors - -These [fine folks](https://github.com/remiprev/her/contributors) helped with Her: - -* [@jfcixmedia](https://github.com/jfcixmedia) -* [@EtienneLem](https://github.com/EtienneLem) -* [@rafaelss](https://github.com/rafaelss) -* [@tysontate](https://github.com/tysontate) -* [@nfo](https://github.com/nfo) -* [@simonprevost](https://github.com/simonprevost) -* [@jmlacroix](https://github.com/jmlacroix) -* [@thomsbg](https://github.com/thomsbg) -* [@calmyournerves](https://github.com/calmyournerves) -* [@luflux](https://github.com/luxflux) -* [@simonc](https://github.com/simonc) -* [@pencil](https://github.com/pencil) -* [@joanniclaborde](https://github.com/joanniclaborde) -* [@seanreads](https://github.com/seanreads) -* [@jonkarna](https://github.com/jonkarna) -* [@aclevy](https://github.com/aclevy) -* [@stevschmid](https://github.com/stevschmid) -* [@prognostikos](https://github.com/prognostikos) -* [@dturnerTS](https://github.com/dturnerTS) -* [@kritik](https://github.com/kritik) - ## License -Her is © 2012-2013 [Rémi Prévost](http://exomel.com) and may be freely distributed under the [MIT license](https://github.com/remiprev/her/blob/master/LICENSE). See the `LICENSE` file. +Her is © 2012-2026 [Rémi Prévost](http://exomel.com) and may be freely distributed under the [MIT license](https://github.com/remiprev/her/blob/master/LICENSE). See the `LICENSE` file. From da7129282af212eee26a3cff44549ee2d0fd4a5d Mon Sep 17 00:00:00 2001 From: Dale Stevens Date: Tue, 31 Mar 2026 00:25:09 -0600 Subject: [PATCH 31/31] Replace RuboCop with Qlty for code quality Remove RuboCop 0.54 config (2018) and todo file in favor of Qlty with actionlint, markdownlint, trivy, trufflehog, and yamllint. --- .qlty/qlty.toml | 57 ++++++++++++ .rubocop.yml | 31 ------- .rubocop_todo.yml | 232 ---------------------------------------------- 3 files changed, 57 insertions(+), 263 deletions(-) create mode 100644 .qlty/qlty.toml delete mode 100644 .rubocop.yml delete mode 100644 .rubocop_todo.yml diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml new file mode 100644 index 00000000..17e43a98 --- /dev/null +++ b/.qlty/qlty.toml @@ -0,0 +1,57 @@ +# This file configures Qlty code quality and coverage tools +# For configuration guide: https://qlty.sh/d/config +# For full reference: https://qlty.sh/d/qlty-toml + +config_version = "0" + +exclude_patterns = [ + "*_min.*", + "*-min.*", + "*.min.*", + "**/*.d.ts", + "**/node_modules/**", + "**/vendor/**", + "**/coverage/**", + "**/tmp/**", + "**/pkg/**", + "**/gemfiles/**", + "**/.bundle/**", +] + +test_patterns = [ + "**/test/**", + "**/spec/**", + "**/*.test.*", + "**/*.spec.*", + "**/*_test.*", + "**/*_spec.*", +] + +[smells] +mode = "comment" + +[[source]] +name = "default" +default = true + +[[plugin]] +name = "actionlint" + +[[plugin]] +name = "markdownlint" +drivers = ["lint"] +mode = "comment" + +[[plugin]] +name = "ripgrep" +mode = "comment" + +[[plugin]] +name = "trivy" +drivers = ["config"] + +[[plugin]] +name = "trufflehog" + +[[plugin]] +name = "yamllint" diff --git a/.rubocop.yml b/.rubocop.yml deleted file mode 100644 index 675d73b2..00000000 --- a/.rubocop.yml +++ /dev/null @@ -1,31 +0,0 @@ -inherit_from: .rubocop_todo.yml - -Layout/CaseIndentation: - EnforcedStyle: end - -Layout/EmptyLinesAroundClassBody: - EnforcedStyle: beginning_only - -Lint/AmbiguousBlockAssociation: - Enabled: false - -Lint/AmbiguousOperator: - Enabled: false - -Lint/AssignmentInCondition: - Enabled: false - -Naming/UncommunicativeMethodParamName: - AllowedNames: io, id, to, by - -Style/AsciiComments: - Enabled: false - -Style/GuardClause: - Enabled: false - -Style/RescueModifier: - Enabled: false - -Style/SymbolArray: - Enabled: false diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml deleted file mode 100644 index a54fa0a6..00000000 --- a/.rubocop_todo.yml +++ /dev/null @@ -1,232 +0,0 @@ -# This configuration was generated by -# `rubocop --auto-gen-config` -# on 2018-05-16 14:33:11 -0300 using RuboCop version 0.54.0. -# The point is for the user to remove these configuration records -# one by one as the offenses are removed from the code base. -# Note that changes in the inspected code, or installation of new -# versions of RuboCop, may require this file to be generated again. - -# Offense count: 2 -# Configuration parameters: Include. -# Include: **/*.gemfile, **/Gemfile, **/gems.rb -Bundler/DuplicatedGem: - Exclude: - - 'Gemfile' - -# Offense count: 1 -Lint/EmptyWhen: - Exclude: - - 'lib/her/model/parse.rb' - -# Offense count: 1 -Lint/IneffectiveAccessModifier: - Exclude: - - 'lib/her/api.rb' - -# Offense count: 6 -# Cop supports --auto-correct. -# Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods. -Lint/UnusedMethodArgument: - Exclude: - - 'lib/her/api.rb' - - 'lib/her/model/associations/association.rb' - - 'lib/her/model/attributes.rb' - - 'lib/her/model/orm.rb' - - 'spec/model/attributes_spec.rb' - -# Offense count: 1 -# Configuration parameters: ContextCreatingMethods, MethodCreatingMethods. -Lint/UselessAccessModifier: - Exclude: - - 'lib/her/api.rb' - -# Offense count: 10 -Metrics/AbcSize: - Max: 38 - -# Offense count: 91 -# Configuration parameters: CountComments, ExcludedMethods. -Metrics/BlockLength: - Max: 694 - -# Offense count: 4 -Metrics/CyclomaticComplexity: - Max: 12 - -# Offense count: 10 -# Configuration parameters: CountComments. -Metrics/MethodLength: - Max: 28 - -# Offense count: 1 -# Configuration parameters: CountComments. -Metrics/ModuleLength: - Max: 106 - -# Offense count: 4 -Metrics/PerceivedComplexity: - Max: 12 - -# Offense count: 4 -Naming/MemoizedInstanceVariableName: - Exclude: - - 'lib/her/model/associations.rb' - - 'lib/her/model/attributes.rb' - - 'lib/her/model/relation.rb' - -# Offense count: 7 -# Configuration parameters: NamePrefix, NamePrefixBlacklist, NameWhitelist, MethodDefinitionMacros. -# NamePrefix: is_, has_, have_ -# NamePrefixBlacklist: is_, has_, have_ -# NameWhitelist: is_a? -# MethodDefinitionMacros: define_method, define_singleton_method -Naming/PredicateName: - Exclude: - - 'spec/**/*' - - 'lib/her/model/associations.rb' - - 'lib/her/model/attributes.rb' - - 'lib/her/model/base.rb' - - 'lib/her/model/deprecated_methods.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -Performance/RegexpMatch: - Exclude: - - 'spec/support/macros/model_macros.rb' - -# Offense count: 23 -Style/Documentation: - Enabled: false - -# Offense count: 21 -# Cop supports --auto-correct. -Style/Encoding: - Enabled: false - -# Offense count: 2 -# Cop supports --auto-correct. -Style/ExpandPathArguments: - Exclude: - - 'her.gemspec' - - 'spec/spec_helper.rb' - -# Offense count: 59 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle. -# SupportedStyles: when_needed, always, never -Style/FrozenStringLiteralComment: - Enabled: false - -# Offense count: 89 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, UseHashRocketsWithSymbolValues, PreferHashRocketsForNonAlnumEndingSymbols. -# SupportedStyles: ruby19, hash_rockets, no_mixed_keys, ruby19_no_mixed_keys -Style/HashSyntax: - Enabled: false - -# Offense count: 6 -# Cop supports --auto-correct. -Style/IfUnlessModifier: - Exclude: - - 'lib/her/json_api/model.rb' - - 'lib/her/model/associations/association_proxy.rb' - - 'lib/her/model/nested_attributes.rb' - - 'lib/her/model/orm.rb' - - 'lib/her/model/parse.rb' - -# Offense count: 2 -Style/MethodMissing: - Exclude: - - 'lib/her/model/associations/association_proxy.rb' - - 'lib/her/model/relation.rb' - -# Offense count: 2 -# Cop supports --auto-correct. -Style/MutableConstant: - Exclude: - - 'lib/her/model/http.rb' - - 'lib/her/version.rb' - -# Offense count: 3 -# Cop supports --auto-correct. -Style/PerlBackrefs: - Exclude: - - 'lib/her/model/paths.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, AllowInnerSlashes. -# SupportedStyles: slashes, percent_r, mixed -Style/RegexpLiteral: - Exclude: - - 'lib/her/model/paths.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: ConvertCodeThatCanStartToReturnNil, Whitelist. -# Whitelist: present?, blank?, presence, try -Style/SafeNavigation: - Exclude: - - 'spec/model/orm_spec.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle. -# SupportedStyles: use_perl_names, use_english_names -Style/SpecialGlobalVars: - Exclude: - - 'her.gemspec' - -# Offense count: 1613 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline. -# SupportedStyles: single_quotes, double_quotes -Style/StringLiterals: - Enabled: false - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle. -# SupportedStyles: single_quotes, double_quotes -Style/StringLiteralsInInterpolation: - Exclude: - - 'lib/her/model/introspection.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: IgnoredMethods. -# IgnoredMethods: respond_to, define_method -Style/SymbolProc: - Exclude: - - 'lib/her/model/parse.rb' - -# Offense count: 2 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, AllowSafeAssignment. -# SupportedStyles: require_parentheses, require_no_parentheses, require_parentheses_when_complex -Style/TernaryParentheses: - Exclude: - - 'lib/her/model/http.rb' - - 'lib/her/model/orm.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyleForMultiline. -# SupportedStylesForMultiline: comma, consistent_comma, no_comma -Style/TrailingCommaInHashLiteral: - Exclude: - - 'lib/her/middleware/json_api_parser.rb' - -# Offense count: 6 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, MinSize, WordRegex. -# SupportedStyles: percent, brackets -Style/WordArray: - Exclude: - - 'spec/model/orm_spec.rb' - -# Offense count: 409 -# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. -# URISchemes: http, https -Metrics/LineLength: - Max: 377