diff --git a/Gemfile.lock b/Gemfile.lock index 52571a4..271d423 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,7 +4,6 @@ PATH paddle (2.10) cgi faraday (>= 2.14.3, < 3) - ostruct (~> 0.6.0) GEM remote: https://rubygems.org/ @@ -45,7 +44,6 @@ GEM minitest (5.27.0) net-http (0.9.1) uri (>= 0.11.1) - ostruct (0.6.3) parallel (2.1.0) parser (3.3.11.1) ast (~> 2.4.1) diff --git a/README.md b/README.md index 197b300..48b18da 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,21 @@ end The gem maps as closely as we can to the Paddle API so you can easily convert API examples to gem code. -Responses are created as objects like `Paddle::Product`. Having types like `Paddle::Product` is handy for understanding what type of object you're working with. They're built using OpenStruct so you can easily access data in a Ruby-ish way. +Responses are created as objects like `Paddle::Product`. Having types like `Paddle::Product` is handy for understanding what type of object you're working with. Attributes can be read with dot notation or like a hash, and nested data is wrapped too: + +```ruby +transaction = Paddle::Transaction.retrieve(id: "txn_abc123") + +transaction.status #=> "completed" +transaction[:status] #=> "completed" +transaction.details.totals.total #=> "1000" +transaction.dig(:items, 0, :price, :id) #=> "pri_abc123" +transaction.missing_attribute #=> nil + +# Convert back to a hash or JSON, including nested data +transaction.to_h +transaction.to_json +``` ### Pagination diff --git a/lib/paddle/object.rb b/lib/paddle/object.rb index ae3c91c..acedac0 100644 --- a/lib/paddle/object.rb +++ b/lib/paddle/object.rb @@ -1,23 +1,66 @@ -require "ostruct" - module Paddle - class Object < OpenStruct - def initialize(attributes) - super to_ostruct(attributes) - end - - def to_ostruct(obj) - if obj.is_a?(Hash) - OpenStruct.new(obj.map { |key, val| [ key, to_ostruct(val) ] }.to_h) - elsif obj.is_a?(Array) - obj.map { |o| to_ostruct(o) } - else # Assumed to be a primitive value - obj - end + # A lightweight wrapper around API response data. Attributes can be read with dot notation + # (object.id) or like a hash (object[:id] or object["id"]). Nested hashes are wrapped too. + class Object + def initialize(attributes = {}) + @attributes = {} + (attributes || {}).each { |key, val| self[key] = val } + end + + def [](key) + @attributes[key.to_sym] + end + + def []=(key, val) + @attributes[key.to_sym] = wrap(val) + end + + def key?(key) + @attributes.key?(key.to_sym) + end + + def dig(key, *rest) + val = self[key] + rest.empty? || val.nil? ? val : val.dig(*rest) end + def each_pair(&block) + return enum_for(:each_pair) unless block_given? + + @attributes.each_pair(&block) + self + end + + # Returns the attributes as a hash, with nested objects converted to hashes too + def to_h + @attributes.transform_values { |val| unwrap(val) } + end + + def as_json(*) + to_h + end + + def to_json(*args) + to_h.to_json(*args) + end + + def ==(other) + other.is_a?(Paddle::Object) && to_h == other.to_h + end + alias eql? == + + def hash + to_h.hash + end + + def inspect + attrs = @attributes.map { |key, val| "#{key}=#{val.inspect}" }.join(", ") + "#<#{self.class.name}#{" " unless attrs.empty?}#{attrs}>" + end + alias to_s inspect + def update(**params) - method_missing :update unless klass.respond_to? :update + raise NoMethodError, "undefined method 'update' for #{klass}" unless klass.respond_to? :update primary_attributes = klass.method(:update).parameters.select { |(type, name)| type == :keyreq }.map(&:last) # Identified by whatever is a required named parameter. @@ -36,5 +79,37 @@ def update(**params) def klass self.class end + + # Unknown attributes return nil, like a hash + def method_missing(name, *args) + if name.end_with?("=") && args.size == 1 + self[name.to_s.chomp("=")] = args.first + elsif args.empty? && !name.end_with?("=", "?", "!") + self[name] + else + super + end + end + + def respond_to_missing?(name, include_private = false) + key?(name.to_s.chomp("=")) || super + end + + def wrap(val) + case val + when Paddle::Object then val + when Hash then Paddle::Object.new(val) + when Array then val.map { |v| wrap(v) } + else val + end + end + + def unwrap(val) + case val + when Paddle::Object then val.to_h + when Array then val.map { |v| unwrap(v) } + else val + end + end end end diff --git a/paddle.gemspec b/paddle.gemspec index cdebe65..58b948d 100644 --- a/paddle.gemspec +++ b/paddle.gemspec @@ -27,6 +27,5 @@ Gem::Specification.new do |spec| spec.require_paths = [ "lib" ] spec.add_dependency "faraday", ">= 2.14.3", "< 3" - spec.add_dependency "ostruct", "~> 0.6.0" spec.add_dependency "cgi" end diff --git a/test/paddle/object_test.rb b/test/paddle/object_test.rb index 2c7ca29..902c970 100644 --- a/test/paddle/object_test.rb +++ b/test/paddle/object_test.rb @@ -15,7 +15,63 @@ def test_nested_number def test_array object = Paddle::Object.new(foo: [ { bar: :baz } ]) - assert_equal OpenStruct, object.foo.first.class + assert_equal Paddle::Object, object.foo.first.class assert_equal :baz, object.foo.first.bar end + + def test_hash_access + object = Paddle::Object.new("id" => "txn_abc123") + assert_equal "txn_abc123", object[:id] + assert_equal "txn_abc123", object["id"] + end + + def test_missing_attribute_returns_nil + object = Paddle::Object.new(foo: "bar") + assert_nil object.baz + refute object.respond_to?(:baz) + assert object.respond_to?(:foo) + end + + def test_setting_attributes + object = Paddle::Object.new(foo: "bar") + object.foo = "baz" + object[:qux] = { quux: 1 } + assert_equal "baz", object.foo + assert_equal 1, object.qux.quux + end + + def test_attributes_named_after_private_methods + object = Paddle::Object.new(format: "pdf", test: true) + assert_equal "pdf", object.format + assert_equal true, object.test + end + + def test_unknown_method_with_arguments_raises + assert_raises(NoMethodError) { Paddle::Object.new(foo: "bar").foo(1) } + end + + def test_dig + object = Paddle::Object.new(items: [ { price: { id: "pri_abc123" } } ]) + assert_equal "pri_abc123", object.dig(:items, 0, :price, :id) + assert_nil object.dig(:missing, :id) + end + + def test_to_h_converts_nested_objects + object = Paddle::Object.new(foo: { bar: [ { baz: 1 } ] }) + assert_equal({ foo: { bar: [ { baz: 1 } ] } }, object.to_h) + end + + def test_to_json + object = Paddle::Object.new(foo: { bar: "baz" }) + assert_equal '{"foo":{"bar":"baz"}}', object.to_json + end + + def test_equality + assert_equal Paddle::Object.new(foo: { bar: 1 }), Paddle::Object.new("foo" => { "bar" => 1 }) + refute_equal Paddle::Object.new(foo: 1), Paddle::Object.new(foo: 2) + end + + def test_nil_attributes + assert_equal({}, Paddle::Object.new(nil).to_h) + end end