diff --git a/USAGE.md b/USAGE.md index 4a3b02e..f30c3eb 100644 --- a/USAGE.md +++ b/USAGE.md @@ -9,6 +9,7 @@ - [Validations](#validations) - [Working with objects](#working-with-objects) - [Composition](#composition) +- [Error Handling](#error-handling) - [DSL Reference](#dsl-reference) - [Public Interface](#public-interface) @@ -323,6 +324,77 @@ or `getter: -> { address&.installation&.street }`. The flattening only works for reading: `site.city = "Hamburg"` is discarded, so assign to `site.address.city` instead. +## Error Handling + +Minitwin raises gem-specific exception classes so misuse of the DSL, of composition, of +dynamic aliases, of type coercion, and of the constructors can each be rescued on their own — +or all at once via the shared marker module: + +```ruby +begin + UserTwin.from_object({ id: 42 }) +rescue Minitwin::Error => e + # any Minitwin-raised error, regardless of which of the five classes below +end +``` + +| Class | Ruby base class | Raised when | +|---|---|---| +| `Minitwin::DefinitionError` | `ArgumentError` | The DSL itself is used incorrectly (e.g. `nested` without a block, `setter:` together with a block, `validates:` without ActiveModel). | +| `Minitwin::CompositionError` | `RuntimeError` | A composition source given via `on:` is missing, or the source object doesn't respond to the composed property. | +| `Minitwin::AliasError` | `ArgumentError` | A dynamic alias (`as: -> { ... }`) is invalid, forbidden, or collides with another alias or an existing method. | +| `Minitwin::CoercionError` | `TypeError` | A value assigned to a block/nested property cannot be converted into that nested twin (or a custom `type:` callable raises it explicitly). | +| `Minitwin::ParseError` | `ArgumentError` | Input data is structurally unusable — malformed JSON in `from_json`, or a Hash passed to `from_object` instead of `from_objects`. | + +`AliasError` (raised from the dynamic-alias checks) and `CompositionError` (raised from the +`on:` composition checks) keep their original Ruby base class unchanged — `ArgumentError` and +`RuntimeError` respectively — so existing `rescue ArgumentError` / `rescue RuntimeError` call +sites around those two paths are unaffected by this release. + +Four previously-`RuntimeError` paths, however, now raise an `ArgumentError`/`TypeError` +sibling instead: + +- `property ... setter: ->(v) { ... } do ... end` (a `setter:` combined with a block) — + now `Minitwin::DefinitionError` (`ArgumentError`) +- Assigning an unconvertible value to a block/nested property — now `Minitwin::CoercionError` + (`TypeError`) +- `validates:` used without ActiveModel available — now `Minitwin::DefinitionError` + (`ArgumentError`) +- `from_object` called with a Hash instead of `from_objects` — now `Minitwin::ParseError` + (`ArgumentError`) + +Code that specifically rescued `RuntimeError` around any of these four call sites must widen +the rescue to `StandardError` / `Minitwin::Error`, or catch the new specific class, to keep +catching them. `Minitwin::Error` is an additional, more specific way to catch any of the five +classes above, not a replacement hierarchy. + +This release ships three behavior changes relative to earlier versions: + +1. **`from_json` wraps malformed JSON.** A `JSON::ParserError` raised while parsing is now + wrapped into `Minitwin::ParseError`; the original error is still available via `#cause`: + + ```ruby + begin + UserTwin.from_json("{not valid json") + rescue Minitwin::ParseError => e + e.cause #=> # + end + ``` + + Code that rescued `JSON::ParserError` directly around `from_json` needs to rescue + `Minitwin::ParseError` (or `ArgumentError`) instead. +2. **Four `RuntimeError` sites were reclassified**, as listed above — `rescue RuntimeError` + around them must be widened. +3. **A custom `type:` callable that raises a `Minitwin::Error` subclass now propagates it** + instead of having it swallowed: + + ```ruby + property :x, type: ->(v) { raise Minitwin::ParseError, "bad input" if v.blank?; v } + ``` + + Before this release: a `Minitwin::Error` raised inside a custom `type:` callable was + swallowed and the raw value returned. Now it propagates. + --- ## DSL Reference diff --git a/lib/minitwin.rb b/lib/minitwin.rb index 216d84e..f251075 100644 --- a/lib/minitwin.rb +++ b/lib/minitwin.rb @@ -17,8 +17,11 @@ # Setup Zeitwerk loader for the gem to support Rails autoloading loader = Zeitwerk::Loader.for_gem +loader.ignore("#{__dir__}/minitwin/errors.rb") loader.setup +require_relative "minitwin/errors" + class Minitwin # Constants for internal variable naming INTERNAL_MODEL_PREFIX = "@internal_model__" diff --git a/lib/minitwin/class_methods/constructors.rb b/lib/minitwin/class_methods/constructors.rb index 5f32f7c..dc6ec41 100644 --- a/lib/minitwin/class_methods/constructors.rb +++ b/lib/minitwin/class_methods/constructors.rb @@ -24,6 +24,8 @@ def from_hash(args) def from_json(body) hash = JSON.parse(body, symbolize_names: true) from_hash(hash) + rescue ::JSON::ParserError => exception + raise ParseError, "Invalid JSON passed to #{name}.from_json: #{exception.message}" end # Actually, this is expected to be an `ActionController::Parameters` @@ -40,6 +42,7 @@ def from_params(params) def from_object(model) if model.is_a?(Hash) raise( + ParseError, "Input is not an object. If you want to instantiate a Minitwin with multiple " \ "objects, then use the pluralized 'from_objects'-method." ) diff --git a/lib/minitwin/class_methods/dsl.rb b/lib/minitwin/class_methods/dsl.rb index faa145a..3e72657 100644 --- a/lib/minitwin/class_methods/dsl.rb +++ b/lib/minitwin/class_methods/dsl.rb @@ -90,13 +90,13 @@ def property( ivar = Minitwin::Utils.ivar_name(name) if block_given? - raise "setters are not possible in blocks" if setter + raise DefinitionError, "setters are not possible in blocks" if setter nested_class = create_nested_class(name:, &block) define_method("#{name}=") do |value| coerced = self.class.send(:coerce_value_to_twin, value, nested_class) - raise "Unprocessable input for property '#{name}'." unless coerced.nil? || coerced.is_a?(nested_class) + raise CoercionError, "Unprocessable input for property '#{name}'." unless coerced.nil? || coerced.is_a?(nested_class) instance_variable_set(ivar, coerced) if !@__skip_alias_recompute__ && self.class.dynamic_aliases? @@ -144,7 +144,7 @@ def property( # @rbs as: Symbol | Proc # @rbs return: void def nested(name, as: nil, &block) - raise ArgumentError, "nested requires a block" unless block_given? + raise DefinitionError, "nested requires a block" unless block_given? property(name, as: as, &block) @@ -307,12 +307,13 @@ def build_composition_getter(name:, on:, default:, type:) # Validate model if model.nil? raise( + CompositionError, "Property '#{name}' refers to unknown composition source '#{on}' in #{self.class}. " \ "Ensure the model is provided via from_objects or a reader exists." ) end unless model.respond_to?(name) - raise "The instance of '#{model.class}' does not respond to '#{name}'." + raise CompositionError, "The instance of '#{model.class}' does not respond to '#{name}'." end raw = model.send(name) @@ -373,7 +374,7 @@ def add_validation(name:, validates:) return if validates.nil? return if validates.respond_to?(:empty?) && validates.empty? - raise "Validation is not possible, because activemodel is not available" unless respond_to?(:validates) + raise DefinitionError, "Validation is not possible, because activemodel is not available" unless respond_to?(:validates) if validates.is_a?(Proc) validate do diff --git a/lib/minitwin/class_methods/types_helper.rb b/lib/minitwin/class_methods/types_helper.rb index a1b1042..bf55ffa 100644 --- a/lib/minitwin/class_methods/types_helper.rb +++ b/lib/minitwin/class_methods/types_helper.rb @@ -62,6 +62,12 @@ def attempt_type_coercion(raw_value, type) return raw_value unless type type.call(raw_value) + rescue Minitwin::Error + # Re-raise: a Minitwin::Error (e.g. from a custom type: callable) must propagate + # instead of being swallowed by the broader rescue below, which is for ordinary + # coercion failures only. CoercionError/AliasError/ParseError/DefinitionError are + # all TypeError/ArgumentError subclasses, so this clause has to come first. + raise rescue *Minitwin.send(:coercion_error_classes) raw_value end diff --git a/lib/minitwin/errors.rb b/lib/minitwin/errors.rb new file mode 100644 index 0000000..5f74462 --- /dev/null +++ b/lib/minitwin/errors.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +# rbs_inline: enabled + +class Minitwin + # Marker: `rescue Minitwin::Error` catches every gem-specific error. + module Error; end + + # DSL used incorrectly — fails at class-definition time. + class DefinitionError < ::ArgumentError + include Error + end + + # A source object cannot supply a composed property. + class CompositionError < ::RuntimeError + include Error + end + + # A dynamic alias is invalid, forbidden, or collides. + class AliasError < ::ArgumentError + include Error + end + + # A value cannot be coerced into the expected twin type. + class CoercionError < ::TypeError + include Error + end + + # Input data is structurally unusable (JSON, wrong object type). + class ParseError < ::ArgumentError + include Error + end +end diff --git a/lib/minitwin/initialization.rb b/lib/minitwin/initialization.rb index aee13a7..3147e80 100644 --- a/lib/minitwin/initialization.rb +++ b/lib/minitwin/initialization.rb @@ -138,7 +138,7 @@ def __compute_nested_alias_name__(entry) def __apply_dynamic_alias__(target_method, alias_name) unless alias_name.is_a?(String) || alias_name.is_a?(Symbol) - raise ArgumentError, "Invalid alias name #{alias_name.inspect}: must be a String or Symbol" + raise AliasError, "Invalid alias name #{alias_name.inspect}: must be a String or Symbol" end alias_key = alias_name.to_sym @@ -147,7 +147,7 @@ def __apply_dynamic_alias__(target_method, alias_name) # Security check: prevent aliasing to forbidden method names if FORBIDDEN_ALIAS_NAMES.include?(alias_key) - raise ArgumentError, "Cannot define dynamic alias '#{alias_key}': forbidden method name for security reasons" + raise AliasError, "Cannot define dynamic alias '#{alias_key}': forbidden method name for security reasons" end prev = aliases[target_method] @@ -162,11 +162,11 @@ def __apply_dynamic_alias__(target_method, alias_name) # Collision checks: alias already used by another target or an existing method if aliases_rev.key?(alias_key) && aliases_rev[alias_key] != target_method - raise ArgumentError, "Dynamic alias '#{alias_key}' already defined for '#{aliases_rev[alias_key]}'" + raise AliasError, "Dynamic alias '#{alias_key}' already defined for '#{aliases_rev[alias_key]}'" end if respond_to?(alias_key, true) && aliases_rev[alias_key] != target_method - raise ArgumentError, "Cannot define dynamic alias '#{alias_key}': method already exists" + raise AliasError, "Cannot define dynamic alias '#{alias_key}': method already exists" end # Define forwarding method on the singleton class diff --git a/sig/generated/minitwin/errors.rbs b/sig/generated/minitwin/errors.rbs new file mode 100644 index 0000000..6fada34 --- /dev/null +++ b/sig/generated/minitwin/errors.rbs @@ -0,0 +1,32 @@ +# Generated from lib/minitwin/errors.rb with RBS::Inline + +class Minitwin + # Marker: `rescue Minitwin::Error` catches every gem-specific error. + module Error + end + + # DSL used incorrectly — fails at class-definition time. + class DefinitionError < ::ArgumentError + include Error + end + + # A source object cannot supply a composed property. + class CompositionError < ::RuntimeError + include Error + end + + # A dynamic alias is invalid, forbidden, or collides. + class AliasError < ::ArgumentError + include Error + end + + # A value cannot be coerced into the expected twin type. + class CoercionError < ::TypeError + include Error + end + + # Input data is structurally unusable (JSON, wrong object type). + class ParseError < ::ArgumentError + include Error + end +end diff --git a/test/class_methods/constructors_test.rb b/test/class_methods/constructors_test.rb index 22693b4..4c57469 100644 --- a/test/class_methods/constructors_test.rb +++ b/test/class_methods/constructors_test.rb @@ -161,7 +161,7 @@ class ActiveModelPerson property :name end - error = assert_raises(RuntimeError) do + error = assert_raises(Minitwin::ParseError) do klass.from_object({ name: "test" }) end assert_match(/use.*from_objects/, error.message) diff --git a/test/class_methods/dsl/composition_test.rb b/test/class_methods/dsl/composition_test.rb index b6a9550..f428477 100644 --- a/test/class_methods/dsl/composition_test.rb +++ b/test/class_methods/dsl/composition_test.rb @@ -112,7 +112,7 @@ def klass.collections(*) # Should raise informative error obj = klass.new - error = assert_raises(RuntimeError) { obj.name } + error = assert_raises(Minitwin::CompositionError) { obj.name } assert_match(/unknown composition source/, error.message) end @@ -124,7 +124,7 @@ def klass.collections(*) end obj = klass.from_objects(model: model) - error = assert_raises(RuntimeError) { obj.name } + error = assert_raises(Minitwin::CompositionError) { obj.name } assert_match(/does not respond to/, error.message) end @@ -148,7 +148,7 @@ def klass.collections(*) end obj = klass.from_objects(model: model) - error = assert_raises(RuntimeError) do + error = assert_raises(Minitwin::CompositionError) do obj.name end assert_match(/does not respond to/, error.message) @@ -160,7 +160,7 @@ def klass.collections(*) end obj = klass.new - error = assert_raises(RuntimeError) do + error = assert_raises(Minitwin::CompositionError) do obj.name end assert_match(/unknown composition source/, error.message) diff --git a/test/class_methods/dsl/nested_test.rb b/test/class_methods/dsl/nested_test.rb index c3f9dec..8ea080e 100644 --- a/test/class_methods/dsl/nested_test.rb +++ b/test/class_methods/dsl/nested_test.rb @@ -172,8 +172,8 @@ class StringAliasNestedTwin < Minitwin assert_equal "test", obj.my_nested_prop.value end - test "nested without a block raises an ArgumentError" do - error = assert_raises(ArgumentError) do + test "nested without a block raises a Minitwin::DefinitionError" do + error = assert_raises(Minitwin::DefinitionError) do Class.new(Minitwin) do nested :invalid end diff --git a/test/class_methods/dsl/properties_test.rb b/test/class_methods/dsl/properties_test.rb index ba8937e..75a0f78 100644 --- a/test/class_methods/dsl/properties_test.rb +++ b/test/class_methods/dsl/properties_test.rb @@ -181,6 +181,17 @@ class << self assert_match(/setters are not possible in blocks/, klass.caught_error.message) end + test "raises Minitwin::CoercionError when a block property receives an unconvertible value" do + klass = Class.new(Minitwin) do + property :nested_thing do + property :value + end + end + + error = assert_raises(Minitwin::CoercionError) { klass.new(nested_thing: 42) } + assert_match(/Unprocessable input for property/, error.message) + end + # --- defaults / types / boolean names (error handling) --- test "property without type returns nil" do diff --git a/test/class_methods/dsl/validation_test.rb b/test/class_methods/dsl/validation_test.rb index 2b1ad3e..457dd55 100644 --- a/test/class_methods/dsl/validation_test.rb +++ b/test/class_methods/dsl/validation_test.rb @@ -63,7 +63,7 @@ def self.name end # Should raise when trying to add validations without ActiveModel - error = assert_raises(RuntimeError) do + error = assert_raises(Minitwin::DefinitionError) do base.class_eval do property :name, validates: { presence: true } end diff --git a/test/errors_test.rb b/test/errors_test.rb new file mode 100644 index 0000000..91add70 --- /dev/null +++ b/test/errors_test.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "test_helper" +require "minitwin" + +class ErrorsTest < ActiveSupport::TestCase + test "Minitwin::Error is a module, not a class" do + assert_kind_of Module, Minitwin::Error + refute_kind_of Class, Minitwin::Error + end + + ERROR_CLASSES = { + Minitwin::DefinitionError => ::ArgumentError, + Minitwin::CompositionError => ::RuntimeError, + Minitwin::AliasError => ::ArgumentError, + Minitwin::CoercionError => ::TypeError, + Minitwin::ParseError => ::ArgumentError + }.freeze + + ERROR_CLASSES.each do |klass, base_class| + test "#{klass} is catchable via Minitwin::Error and via #{base_class}" do + caught_via_marker = begin + raise klass, "boom" + rescue Minitwin::Error + true + end + assert caught_via_marker + + caught_via_base = begin + raise klass, "boom" + rescue base_class + true + end + assert caught_via_base + end + end + + test "from_json wraps JSON::ParserError as Minitwin::ParseError and preserves #cause" do + klass = Class.new(Minitwin) do + property :name + end + + error = assert_raises(Minitwin::ParseError) { klass.from_json("{not valid json") } + assert_kind_of ::JSON::ParserError, error.cause + end + + test "attempt_type_coercion re-raises a Minitwin::Error instead of swallowing it" do + raising_type = Object.new + def raising_type.call(_value) + raise Minitwin::CoercionError, "boom" + end + + klass = Class.new(Minitwin) + + assert_raises(Minitwin::CoercionError) do + klass.send(:attempt_type_coercion, "x", raising_type) + end + end +end diff --git a/test/initialization_test.rb b/test/initialization_test.rb index 1d43d53..58da9de 100644 --- a/test/initialization_test.rb +++ b/test/initialization_test.rb @@ -296,6 +296,7 @@ def self.allowed_attribute_keys klass = Class.new(Minitwin) do property :value, as: -> { :to_s } end + # Regression guard: AliasError must stay catchable as a plain ArgumentError. Do not narrow. assert_raises(ArgumentError) { klass.new(value: 1) } end @@ -303,7 +304,7 @@ def self.allowed_attribute_keys klass = Class.new(Minitwin) do property :value, as: -> { :send } end - err = assert_raises(ArgumentError) { klass.new(value: 1) } + err = assert_raises(Minitwin::AliasError) { klass.new(value: 1) } assert_includes err.message, "forbidden" end @@ -326,7 +327,7 @@ def self.allowed_attribute_keys end # Should raise on collision during initialization - assert_raises(ArgumentError) do + assert_raises(Minitwin::AliasError) do klass.new(first: "a", second: "b") end end @@ -343,7 +344,7 @@ def existing_method end # Should raise on collision during initialization - assert_raises(ArgumentError) do + assert_raises(Minitwin::AliasError) do klass.new(name: "test", other: "value") end end @@ -407,28 +408,28 @@ def existing_method klass = Class.new(Minitwin) do property :x, as: -> { :binding } end - assert_raises(ArgumentError) { klass.new(x: 1) } + assert_raises(Minitwin::AliasError) { klass.new(x: 1) } end test "to_proc cannot be used as dynamic alias" do klass = Class.new(Minitwin) do property :x, as: -> { :to_proc } end - assert_raises(ArgumentError) { klass.new(x: 1) } + assert_raises(Minitwin::AliasError) { klass.new(x: 1) } end test "freeze cannot be used as dynamic alias" do klass = Class.new(Minitwin) do property :x, as: -> { :freeze } end - assert_raises(ArgumentError) { klass.new(x: 1) } + assert_raises(Minitwin::AliasError) { klass.new(x: 1) } end test "raises ArgumentError with clear message when as: proc returns non-string/symbol" do klass = Class.new(Minitwin) do property :x, as: -> { 42 } end - error = assert_raises(ArgumentError) { klass.new(x: 1) } + error = assert_raises(Minitwin::AliasError) { klass.new(x: 1) } assert_match(/invalid alias name/i, error.message) assert_match(/42/, error.message) end