From 6c3e9105f43ec15bf0ed8bf368807638673cd4d9 Mon Sep 17 00:00:00 2001 From: Stephen Mariano Cabrera Date: Wed, 31 Dec 2025 12:34:13 -0800 Subject: [PATCH] Raise on non-existent kwargs Currently filters will allow you to include keyword arguments that don't exist, leading to potentially confusing outcomes for users. This change makes it so that unsupported keyword arguments will raise an exception, while still allowing users to extend the available options if needed. --- CHANGELOG.md | 1 + lib/active_interaction/filter.rb | 14 ++++++++ .../filters/abstract_date_time_filter.rb | 4 +++ .../filters/array_filter.rb | 4 +++ .../filters/decimal_filter.rb | 4 +++ lib/active_interaction/filters/hash_filter.rb | 4 +++ .../filters/integer_filter.rb | 4 +++ .../filters/interface_filter.rb | 4 +++ .../filters/object_filter.rb | 4 +++ .../filters/record_filter.rb | 4 +++ .../filters/string_filter.rb | 4 +++ spec/active_interaction/base_spec.rb | 36 +++++++++++++++++++ spec/active_interaction/errors_spec.rb | 4 +-- 13 files changed, 89 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbdd3f70..fd3ae4d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Added - Support for Ruby 3.4. + - Added per-filter option validation. Each filter type now validates its own specific options and raises `ArgumentError` when passed unrecognized options. ## Fixed diff --git a/lib/active_interaction/filter.rb b/lib/active_interaction/filter.rb index 0e49bb57..f0dbd921 100644 --- a/lib/active_interaction/filter.rb +++ b/lib/active_interaction/filter.rb @@ -51,6 +51,13 @@ def factory(slug) CLASSES.fetch(slug) { raise MissingFilterError, slug.inspect } end + # Returns the list of allowed options for this filter type. + # + # @return [Array] + def allowed_options + %i[desc default] + end + private # @param slug [Symbol] @@ -66,6 +73,7 @@ def register(slug) # # @option options [Object] :default Fallback value to use when given `nil`. def initialize(name, options = {}, &block) + validate_options!(options) @name = name @options = options.dup @filters = {} @@ -229,6 +237,12 @@ def describe(value) "(Object doesn't support #inspect)" end + def validate_options!(options) + if (invalid_options = options.keys - self.class.allowed_options).any? + raise ArgumentError, "invalid options: #{invalid_options.join(', ')}" + end + end + def raw_default(context) value = options.fetch(:default) return value unless value.is_a?(Proc) diff --git a/lib/active_interaction/filters/abstract_date_time_filter.rb b/lib/active_interaction/filters/abstract_date_time_filter.rb index 5a678d58..20550018 100644 --- a/lib/active_interaction/filters/abstract_date_time_filter.rb +++ b/lib/active_interaction/filters/abstract_date_time_filter.rb @@ -8,6 +8,10 @@ module ActiveInteraction # # @private class AbstractDateTimeFilter < Filter + def self.allowed_options + super + %i[format] + end + def database_column_type self.class.slug end diff --git a/lib/active_interaction/filters/array_filter.rb b/lib/active_interaction/filters/array_filter.rb index 3c0d08f1..5a26c6b4 100644 --- a/lib/active_interaction/filters/array_filter.rb +++ b/lib/active_interaction/filters/array_filter.rb @@ -38,6 +38,10 @@ class ArrayFilter < Filter register :array + def self.allowed_options + super + %i[index_errors] + end + def process(value, context) input = super diff --git a/lib/active_interaction/filters/decimal_filter.rb b/lib/active_interaction/filters/decimal_filter.rb index a3f41ac9..bef2bf4e 100644 --- a/lib/active_interaction/filters/decimal_filter.rb +++ b/lib/active_interaction/filters/decimal_filter.rb @@ -19,6 +19,10 @@ class Base # rubocop:disable Lint/EmptyClass class DecimalFilter < AbstractNumericFilter register :decimal + def self.allowed_options + super + %i[digits] + end + private def digits diff --git a/lib/active_interaction/filters/hash_filter.rb b/lib/active_interaction/filters/hash_filter.rb index 5819ff6c..d59a24a5 100644 --- a/lib/active_interaction/filters/hash_filter.rb +++ b/lib/active_interaction/filters/hash_filter.rb @@ -25,6 +25,10 @@ class HashFilter < Filter register :hash + def self.allowed_options + super + %i[strip] + end + def process(value, context) # rubocop:disable Metrics/AbcSize input = super diff --git a/lib/active_interaction/filters/integer_filter.rb b/lib/active_interaction/filters/integer_filter.rb index 6f145097..4b7aabda 100644 --- a/lib/active_interaction/filters/integer_filter.rb +++ b/lib/active_interaction/filters/integer_filter.rb @@ -20,6 +20,10 @@ class Base # rubocop:disable Lint/EmptyClass class IntegerFilter < AbstractNumericFilter register :integer + def self.allowed_options + super + %i[base] + end + private def base diff --git a/lib/active_interaction/filters/interface_filter.rb b/lib/active_interaction/filters/interface_filter.rb index f8e9c3f3..dbfbbd77 100644 --- a/lib/active_interaction/filters/interface_filter.rb +++ b/lib/active_interaction/filters/interface_filter.rb @@ -28,6 +28,10 @@ class Base # rubocop:disable Lint/EmptyClass class InterfaceFilter < Filter register :interface + def self.allowed_options + super + %i[from methods] + end + def initialize(name, options = {}, &block) if options.key?(:methods) && options.key?(:from) raise InvalidFilterError, diff --git a/lib/active_interaction/filters/object_filter.rb b/lib/active_interaction/filters/object_filter.rb index cc6cbe7d..8cf04e56 100644 --- a/lib/active_interaction/filters/object_filter.rb +++ b/lib/active_interaction/filters/object_filter.rb @@ -28,6 +28,10 @@ class Base # rubocop:disable Lint/EmptyClass class ObjectFilter < Filter register :object + def self.allowed_options + super + %i[class converter] + end + private def klass diff --git a/lib/active_interaction/filters/record_filter.rb b/lib/active_interaction/filters/record_filter.rb index 35abe813..271ba202 100644 --- a/lib/active_interaction/filters/record_filter.rb +++ b/lib/active_interaction/filters/record_filter.rb @@ -30,6 +30,10 @@ class Base # rubocop:disable Lint/EmptyClass class RecordFilter < Filter register :record + def self.allowed_options + super + %i[class finder] + end + private def klass diff --git a/lib/active_interaction/filters/string_filter.rb b/lib/active_interaction/filters/string_filter.rb index 49a6209d..cc426cf2 100644 --- a/lib/active_interaction/filters/string_filter.rb +++ b/lib/active_interaction/filters/string_filter.rb @@ -20,6 +20,10 @@ class Base # rubocop:disable Lint/EmptyClass class StringFilter < Filter register :string + def self.allowed_options + super + %i[strip] + end + private def strip? diff --git a/spec/active_interaction/base_spec.rb b/spec/active_interaction/base_spec.rb index ab5b2e1a..e1343cea 100644 --- a/spec/active_interaction/base_spec.rb +++ b/spec/active_interaction/base_spec.rb @@ -140,6 +140,42 @@ def execute end.to raise_error NoMethodError end + it 'raises an error for a non-existent kwarg' do + expect do + Class.new(TestInteraction) do + string :beverage, type: :sparkling_wine, bubbles: true + end + end.to raise_error ArgumentError, /invalid options: type, bubbles/ + end + + it 'allows extending allowed_options for custom filters' do + Class.new(ActiveInteraction::Filter) do + register :custom + + def self.allowed_options + super + %i[custom_option] + end + + private + + def matches?(value) + value.is_a?(String) + end + end + + extended_class = Class.new(TestInteraction) do + custom :test_field, custom_option: true + end + + expect { extended_class.new }.not_to raise_error + end + + it 'filters validate their own options' do + expect(ActiveInteraction::StringFilter.allowed_options).to include(:desc, :default, :strip) + expect(ActiveInteraction::IntegerFilter.allowed_options).to include(:desc, :default, :base) + expect(ActiveInteraction::Filter.allowed_options).to eq(%i[desc default]) + end + it do expect do Class.new(TestInteraction) do diff --git a/spec/active_interaction/errors_spec.rb b/spec/active_interaction/errors_spec.rb index 74f602df..bb5b5b7f 100644 --- a/spec/active_interaction/errors_spec.rb +++ b/spec/active_interaction/errors_spec.rb @@ -11,8 +11,8 @@ let(:klass) do Class.new(ActiveInteraction::Base) do - string :attribute, defualt: nil - array :array, defualt: nil + string :attribute, default: nil + array :array, default: nil def self.name @name ||= SecureRandom.hex