From e978714754f7ed8e3ca5f8ca6002ce10aeca9d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Tue, 28 Apr 2020 18:22:55 -0400 Subject: [PATCH 01/26] Add EntitySet class to perform lazy loading of child properties/entities on requested instead of on initialization --- lib/bootic_client/entity.rb | 136 ++++++++++++++++++++++++------------ 1 file changed, 91 insertions(+), 45 deletions(-) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index 959a8fd..33b6693 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -5,7 +5,7 @@ module EnumerableEntity include Enumerable def each(&block) - self[:items].each &block + entities.get(:items).each &block end def full_set @@ -13,7 +13,7 @@ def full_set Enumerator.new do |yielder| loop do - page.each{|item| yielder.yield item } + page.each { |item| yielder.yield item } raise StopIteration unless page.has_rel?(:next) page = page.next end @@ -23,7 +23,7 @@ def full_set class Entity - CURIE_EXP = /(.+):(.+)/.freeze + CURIE_NS = 'btc' CURIES_REL = 'curies'.freeze SPECIAL_PROP_EXP = /^_.+/.freeze @@ -32,7 +32,7 @@ class Entity def initialize(attrs, client, top: self) @attrs = attrs.kind_of?(Hash) ? attrs : {} @client, @top = client, top - build! + self.extend EnumerableEntity if iterable? end @@ -41,8 +41,7 @@ def to_hash end def [](key) - key = key.to_sym - has_property?(key) ? properties[key] : entities[key] + has_property?(key) ? properties.get(key) : entities.get(key) end def has?(prop_name) @@ -58,9 +57,15 @@ def inspect end def properties - @properties ||= attrs.select{|k,v| !(k =~ SPECIAL_PROP_EXP)}.each_with_object({}) do |(k,v),memo| - memo[k.to_sym] = Entity.wrap(v, client: client, top: top) - end + @properties ||= EntitySet.new(attrs.select { |k,v| !(k =~ SPECIAL_PROP_EXP) }, client, top) + end + + def entities + @entities ||= EntitySet.new(attrs.fetch('_embedded', {}), client, top) + end + + def rels + @rels ||= RelationSet.new(attrs.fetch('_links', {}), client, top, curies) end def links @@ -72,7 +77,7 @@ def self.wrap(obj, client: nil, top: nil) when Hash new(obj, client, top: top) when Array - obj.map{|e| wrap(e, client: client, top: top)} + obj.map { |e| wrap(e, client: client, top: top) } else obj end @@ -81,11 +86,11 @@ def self.wrap(obj, client: nil, top: nil) def method_missing(name, *args, &block) if !block_given? if has_property?(name) - self[name] + properties.get(name) elsif has_entity?(name) - entities[name] + entities.get(name) elsif has_rel?(name) - rels[name].run(*args) + rels.get(name).run(*args) else super end @@ -98,54 +103,95 @@ def respond_to_missing?(method_name, include_private = false) has?(method_name) end - def has_property?(prop_name) - properties.has_key? prop_name.to_sym + def has_property?(name) + properties.has?(name) end - def has_entity?(prop_name) - entities.has_key? prop_name.to_sym + def has_entity?(name) + entities.has?(name) end - def has_rel?(prop_name) - rels.has_key? prop_name.to_sym - end - - def rels - @rels ||= ( - links = attrs.fetch('_links', {}) - links.each_with_object({}) do |(rel,rel_attrs),memo| - if rel =~ CURIE_EXP - _, curie_namespace, rel = rel.split(CURIE_EXP) - if curie = curies.find{|c| c['name'] == curie_namespace} - rel_attrs['docs'] = Relation.expand(curie['href'], rel: rel) - end - end - if rel != CURIES_REL - rel_attrs['name'] = rel - memo[rel.to_sym] = Relation.new(rel_attrs, client) - end - end - ) + def has_rel?(name) + rels.has?(name) end private attr_reader :client, :top, :attrs + def curies + @curies ||= top.links.fetch('curies', []) + end + def iterable? - has_entity?(:items) && entities[:items].respond_to?(:each) + entities.has?(:items) && entities.get(:items).respond_to?(:each) end - def build! - @curies = top.links.fetch('curies', []) - @entities = attrs.fetch('_embedded', {}).each_with_object({}) do |(k,v),memo| - memo[k.to_sym] = if v.kind_of?(Array) - v.map{|ent_attrs| Entity.new(ent_attrs, client, top: top)} - else - Entity.new(v, client, top: top) + class EntitySet + def initialize(attrs, client, top) + @attrs = stringify_keys(attrs || {}) + @client, @top = client, top + @cache = {} + end + + def has?(key) + @attrs.has_key?(key.to_s) + end + + def keys + @keys ||= @attrs.keys + end + + def [](key) + get(key) + end + + def get(key) + @cache[key.to_s] ||= Entity.wrap(@attrs[key.to_s], client: @client, top: @top) + end + + # def all + # keys.map { |k| get(key) } + # end + + private + + def stringify_keys(hash) + hash.inject({}) { |memo,(k,v)| memo[k.to_s] = v; memo } + end + + end + + class RelationSet < EntitySet + + def initialize(attrs, client, top, curies) + super(attrs, client, top) + @curies = curies + end + + def has?(key) + super || @attrs.has_key?("#{CURIE_NS}:#{key}") + end + + def get(key) + return if key.to_s == CURIES_REL + + @cache[key.to_s] ||= begin + key = key.to_s + obj = @attrs[key] + + if obj.nil? and obj = @attrs["#{CURIE_NS}:#{key}"] + if curie = @curies.find { |c| c['name'] == CURIE_NS } + obj['docs'] = Relation.expand(curie['href'], rel: key) + end + end + + Relation.new(obj, @client) end end + end + end end From 19b9788885f8c4a2df3890d3607f8d9ad8111fa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Tue, 28 Apr 2020 18:35:53 -0400 Subject: [PATCH 02/26] Merge stuff from stubbing branch --- lib/bootic_client.rb | 15 +++++ lib/bootic_client/stubbing.rb | 118 ++++++++++++++++++++++++++++++++++ spec/stubbing_spec.rb | 96 +++++++++++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 lib/bootic_client/stubbing.rb create mode 100644 spec/stubbing_spec.rb diff --git a/lib/bootic_client.rb b/lib/bootic_client.rb index 3b5de12..ae1bd8d 100644 --- a/lib/bootic_client.rb +++ b/lib/bootic_client.rb @@ -12,6 +12,8 @@ def strategies end def client(strategy_name, client_opts = {}, &on_new_token) + return @stubber if @stubber + opts = client_opts.dup opts[:logging] = configuration.logging opts[:logger] = configuration.logger if configuration.logging @@ -28,5 +30,18 @@ def configure(&block) def configuration @configuration ||= Configuration.new end + + def stub! + require "bootic_client/stubbing" + @stubber = Stubbing::StubRoot.new + end + + def stub_chain(method_chain, opts = {}) + @stubber.stub_chain(method_chain, opts) + end + + def unstub! + @stubber = nil + end end end diff --git a/lib/bootic_client/stubbing.rb b/lib/bootic_client/stubbing.rb new file mode 100644 index 0000000..9aa045d --- /dev/null +++ b/lib/bootic_client/stubbing.rb @@ -0,0 +1,118 @@ +module BooticClient + module Stubbing + MissingStubError = Class.new(StandardError) + + module Stubber + def from_hash(h) + self + end + + def stub_chain(method_path, opts = {}) + meths = method_path.split('.') + c = 0 + opts = stringify_keys(opts) + + meths.reduce(self) do |stub, method_name| + c += 1 + a = c == meths.size ? opts : {} + stub.stub(method_name, a) + end + end + + def stub(method_name, opts = {}) + key = stub_key(method_name, opts) + if st = stubs[key] + st.stub(method_name, opts) + st + else + stubs[key] = Stub.new(method_name, opts) + end + end + + def method_missing(method_name, *args, &block) + opts = stringify_keys(args.first) + if stub = stubs[stub_key(method_name, opts)] + stub.returns? ? stub.returns : stub + else + raise MissingStubError, "No method stubbed for '#{method_name}' with options #{opts.inspect}" + end + end + + def respond_to_missing?(method_name, include_private = false) + stubs.keys.any?{|k| + k.to_s =~ /^#{method_name.to_s}/ + } + end + + private + def stub_key(method_name, opts) + [method_name.to_s, options_key(opts || {})].join('_') + end + + def options_key(opts) + # sort keys + keys = opts.keys.sort + hash = keys.each_with_object({}) do |key, h| + value = if opts[key].is_a?(Hash) + options_key(opts[key]) + else + opts[key].to_s + end + + h[key] = value + end + + hash.inspect + end + + def stringify_keys(hash) + return hash unless hash.is_a?(Hash) + + hash.each_with_object({}) do |(k, v), h| + h[k.to_s] = stringify_keys(v) + end + end + end + + class StubRoot + include Stubber + + def initialize + @stubs = {} + end + + private + attr_reader :stubs + end + + class Stub + include Stubber + + def initialize(method_name = '', opts = {}) + @method_name, @opts = method_name, opts + @return_data = nil + @stubs = {} + end + + def and_return_data(data) + @return_data = data + self + end + + def returns? + !!@return_data + end + + def returns + if @return_data.is_a?(Array) + @return_data.map{|d| BooticClient::Entity.new(d, nil)} + else + BooticClient::Entity.new(@return_data || {}, nil) + end + end + + private + attr_reader :stubs + end + end +end \ No newline at end of file diff --git a/spec/stubbing_spec.rb b/spec/stubbing_spec.rb new file mode 100644 index 0000000..56f5f7c --- /dev/null +++ b/spec/stubbing_spec.rb @@ -0,0 +1,96 @@ +require 'spec_helper' + +describe "stubbing" do + before do + BooticClient.stub! + end + + after do + BooticClient.unstub! + end + + it "stubs method chains and returns entities" do + BooticClient.stub_chain('root.shops.first').and_return_data({ + 'name' => 'Foo bar' + }) + + client = BooticClient.client(:authorized, access_token: 'abc') + shop = client.root.shops.first + expect(shop).to be_a BooticClient::Entity + expect(shop.name).to eq 'Foo bar' + end + + it "stubs method chains and returns arrays of entities" do + BooticClient.stub_chain('root.shops').and_return_data([ + {'name' => 'Foo bar'}, + {'name' => 'Bar foo'} + ]) + + client = BooticClient.client(:authorized, access_token: 'abc') + shops = client.root.shops + expect(shops.first).to be_a BooticClient::Entity + expect(shops.last).to be_a BooticClient::Entity + expect(shops.first.name).to eq 'Foo bar' + end + + it 'can be chained further' do + BooticClient.stub_chain('foo.bar') + client = BooticClient.client(:authorized, access_token: 'abc') + + stub = client.foo.bar + stub.stub_chain('another.stubz').and_return_data({ + 'id' => 123 + }) + + expect(stub.another.stubz.id).to eq 123 + end + + it 'stubs depending on arguments' do + BooticClient.stub_chain('root.shops', foo: 0).and_return_data({ + 'name' => 'Foo 0' + }) + BooticClient.stub_chain('root.shops', foo: 1).and_return_data({ + 'name' => 'Foo 1' + }) + BooticClient.stub_chain('root.shops', foo: 2, bar: {yup: 'yiss'}).and_return_data({ + 'name' => 'Foo 2' + }) + + client = BooticClient.client(:authorized, access_token: 'abc') + + expect(client.root.shops(foo: 0).name).to eq 'Foo 0' + expect(client.root.shops(foo: 1).name).to eq 'Foo 1' + expect(client.root.shops(foo: 2, bar: {yup: 'yiss'}).name).to eq 'Foo 2' + # arg order shouldn't matter + expect(client.root.shops(bar: {yup: 'yiss'}, foo: 2).name).to eq 'Foo 2' + + expect { + client.root.shops(foo: 2, bar: {yup: 'nope'}) + }.to raise_error BooticClient::Stubbing::MissingStubError + end + + it "stubs multiple chains with arguments" do + BooticClient.stub_chain('one.two', arg: 1).stub_chain('three.four').and_return_data('name' => 'example 1') + BooticClient.stub_chain('one.two', arg: 2).stub_chain('three.four').and_return_data('name' => 'example 2') + + client = BooticClient.client(:authorized, access_token: 'abc') + + expect(client.one.two(arg: 1).three.four.name).to eq 'example 1' + expect(client.one.two(arg: 2).three.four.name).to eq 'example 2' + end + + it "treats symbol and string keys the same" do + BooticClient.stub_chain('one.two', arg: 1).and_return_data('name' => 'example 1') + client = BooticClient.client(:authorized, access_token: 'abc') + + expect(client.one.two("arg" => 1).name).to eq 'example 1' + end + + it "raises known exception if no stub found" do + client = BooticClient.client(:authorized, access_token: 'abc') + + expect{ + client.nope + }.to raise_error BooticClient::Stubbing::MissingStubError + end +end From 88bc8ae31ea959eeb93cdd0b7b22bddb62e9b6a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Tue, 28 Apr 2020 21:46:19 -0400 Subject: [PATCH 03/26] Add PropertySet/EntitySet/RelationSet --- lib/bootic_client/entity.rb | 48 ++++++++++++++++++++++++++++++------- spec/entity_spec.rb | 4 ++-- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index 33b6693..658b002 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -57,7 +57,7 @@ def inspect end def properties - @properties ||= EntitySet.new(attrs.select { |k,v| !(k =~ SPECIAL_PROP_EXP) }, client, top) + @properties ||= PropertySet.new(attrs.select { |k,v| !(k =~ SPECIAL_PROP_EXP) }) end def entities @@ -127,12 +127,9 @@ def iterable? entities.has?(:items) && entities.get(:items).respond_to?(:each) end - - class EntitySet - def initialize(attrs, client, top) + class PropertySet + def initialize(attrs) @attrs = stringify_keys(attrs || {}) - @client, @top = client, top - @cache = {} end def has?(key) @@ -148,7 +145,31 @@ def [](key) end def get(key) - @cache[key.to_s] ||= Entity.wrap(@attrs[key.to_s], client: @client, top: @top) + cache[key.to_s] ||= ( + value = @attrs[key.to_s] + case value + when Hash + PropertySet.new(value) + when Array + value.map { |e| PropertySet.new(e) } + else + value + end + ) + end + + private + + def cache + @cache ||= {} + end + + def method_missing(name, *args, &block) + if has?(name) + get(name) + else + super + end end # def all @@ -160,11 +181,20 @@ def get(key) def stringify_keys(hash) hash.inject({}) { |memo,(k,v)| memo[k.to_s] = v; memo } end + end + + class EntitySet < PropertySet + def initialize(attrs, client, top) + super(attrs) + @client, @top = client, top + end + def get(key) + cache[key.to_s] ||= Entity.wrap(@attrs[key.to_s], client: @client, top: @top) + end end class RelationSet < EntitySet - def initialize(attrs, client, top, curies) super(attrs, client, top) @curies = curies @@ -177,7 +207,7 @@ def has?(key) def get(key) return if key.to_s == CURIES_REL - @cache[key.to_s] ||= begin + cache[key.to_s] ||= begin key = key.to_s obj = @attrs[key] diff --git a/spec/entity_spec.rb b/spec/entity_spec.rb index 0a3cec9..a707e19 100644 --- a/spec/entity_spec.rb +++ b/spec/entity_spec.rb @@ -75,10 +75,10 @@ end it 'wraps object properties as entities' do - expect(entity.an_object).to be_a described_class + expect(entity.an_object).to be_a BooticClient::Entity::PropertySet expect(entity.an_object.name).to eql('Foobar') expect(entity.an_object.age).to eql(22) - expect(entity.an_object.another_object).to be_a described_class + expect(entity.an_object.another_object).to be_a BooticClient::Entity::PropertySet expect(entity.an_object.another_object.foo).to eq 'bar' end From 856fef207c4553e65d26969bb7fb75b271a8b022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Tue, 28 Apr 2020 23:49:51 -0400 Subject: [PATCH 04/26] Entity: Alias #[] to #try --- lib/bootic_client/entity.rb | 27 ++++++++++++++++----------- spec/entity_spec.rb | 12 ++++++++++++ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index 658b002..9cefc15 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -44,6 +44,8 @@ def [](key) has_property?(key) ? properties.get(key) : entities.get(key) end + alias_method :try, :[] + def has?(prop_name) has_property?(prop_name) || has_entity?(prop_name) || has_rel?(prop_name) end @@ -145,17 +147,20 @@ def [](key) end def get(key) - cache[key.to_s] ||= ( - value = @attrs[key.to_s] - case value - when Hash - PropertySet.new(value) - when Array - value.map { |e| PropertySet.new(e) } - else - value - end - ) + cache[key.to_s] ||= wrap(@attrs[key.to_s]) + end + + private + + def wrap(value) + case value + when Hash + PropertySet.new(value) + when Array + value.map { |e| wrap(e) } + else + value + end end private diff --git a/spec/entity_spec.rb b/spec/entity_spec.rb index a707e19..b5f05d3 100644 --- a/spec/entity_spec.rb +++ b/spec/entity_spec.rb @@ -92,6 +92,18 @@ expect(entity.has?(:foobar)).to eql(false) end + it 'responds to #[]' do + expect(entity[:total_items]).to eql(10) + expect(entity[:items]).to be_a(Array) + expect(entity[:foobar]).to eql(nil) + end + + it 'responds to #try (same behaviour as [])' do + expect(entity.try(:total_items)).to eql(10) + expect(entity.try(:items)).to be_a(Array) + expect(entity.try(:foobar)).to eql(nil) + end + describe '#to_hash' do it 'returns original data' do expect(entity.to_hash).to eql(list_payload) From bf3f96a1ad22eb57b1186ef08cc72a8a16eb7f4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Wed, 29 Apr 2020 01:43:54 -0400 Subject: [PATCH 05/26] Add Entity::PropertySet#to_hash --- lib/bootic_client/entity.rb | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index 9cefc15..43fef87 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -27,7 +27,16 @@ class Entity CURIES_REL = 'curies'.freeze SPECIAL_PROP_EXP = /^_.+/.freeze - attr_reader :curies, :entities + def self.wrap(obj, client: nil, top: nil) + case obj + when Hash + new(obj, client, top: top) + when Array + obj.map { |e| wrap(e, client: client, top: top) } + else + obj + end + end def initialize(attrs, client, top: self) @attrs = attrs.kind_of?(Hash) ? attrs : {} @@ -74,17 +83,6 @@ def links @links ||= attrs.fetch('_links', {}) end - def self.wrap(obj, client: nil, top: nil) - case obj - when Hash - new(obj, client, top: top) - when Array - obj.map { |e| wrap(e, client: client, top: top) } - else - obj - end - end - def method_missing(name, *args, &block) if !block_given? if has_property?(name) @@ -134,6 +132,10 @@ def initialize(attrs) @attrs = stringify_keys(attrs || {}) end + def to_hash + @attrs + end + def has?(key) @attrs.has_key?(key.to_s) end From a589ae5ca46a1b35ecbbf3a2eabfc892723875aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Wed, 29 Apr 2020 02:20:02 -0400 Subject: [PATCH 06/26] Add PropertySet#dig --- lib/bootic_client/entity.rb | 4 ++++ spec/entity_spec.rb | 3 +++ 2 files changed, 7 insertions(+) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index 43fef87..59e16d2 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -136,6 +136,10 @@ def to_hash @attrs end + def dig(*keys) + @attrs.dig(*keys) + end + def has?(key) @attrs.has_key?(key.to_s) end diff --git a/spec/entity_spec.rb b/spec/entity_spec.rb index b5f05d3..be07ba5 100644 --- a/spec/entity_spec.rb +++ b/spec/entity_spec.rb @@ -76,10 +76,13 @@ it 'wraps object properties as entities' do expect(entity.an_object).to be_a BooticClient::Entity::PropertySet + expect(entity.an_object.name).to eql('Foobar') expect(entity.an_object.age).to eql(22) expect(entity.an_object.another_object).to be_a BooticClient::Entity::PropertySet expect(entity.an_object.another_object.foo).to eq 'bar' + + expect(entity.an_object.dig('another_object', 'foo')).to eq('bar') end it 'has a #properties object' do From 9161bf6f2ff0514a1e892679536168bb82d058e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Thu, 30 Apr 2020 18:02:00 -0400 Subject: [PATCH 07/26] Make PropertySets enumerable --- lib/bootic_client/entity.rb | 6 ++++++ spec/entity_spec.rb | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index 59e16d2..dd7c253 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -128,6 +128,8 @@ def iterable? end class PropertySet + include Enumerable + def initialize(attrs) @attrs = stringify_keys(attrs || {}) end @@ -156,6 +158,10 @@ def get(key) cache[key.to_s] ||= wrap(@attrs[key.to_s]) end + def each(&block) + keys.each { |k| yield get(k) } + end + private def wrap(value) diff --git a/spec/entity_spec.rb b/spec/entity_spec.rb index be07ba5..24157e2 100644 --- a/spec/entity_spec.rb +++ b/spec/entity_spec.rb @@ -85,6 +85,14 @@ expect(entity.an_object.dig('another_object', 'foo')).to eq('bar') end + it 'allows enumerating over property sets' do + expect(entity.an_object.respond_to?(:each_with_object)).to eq(true) + values = entity.an_object.map { |val| val } + expect(values[0]).to eq("Foobar") + expect(values[1]).to eq(22) + expect(values[2]).to be_a(BooticClient::Entity::PropertySet) + end + it 'has a #properties object' do expect(entity.properties[:total_items]).to eql(10) end From e1b9500cca3be4622ae76d11431c8837be4f8aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Thu, 30 Apr 2020 18:37:44 -0400 Subject: [PATCH 08/26] Enumerating over PropertySet should return key,val --- lib/bootic_client/entity.rb | 2 +- spec/entity_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index dd7c253..edd6bb6 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -159,7 +159,7 @@ def get(key) end def each(&block) - keys.each { |k| yield get(k) } + keys.each { |k| yield k, get(k) } end private diff --git a/spec/entity_spec.rb b/spec/entity_spec.rb index 24157e2..af77f4a 100644 --- a/spec/entity_spec.rb +++ b/spec/entity_spec.rb @@ -87,7 +87,7 @@ it 'allows enumerating over property sets' do expect(entity.an_object.respond_to?(:each_with_object)).to eq(true) - values = entity.an_object.map { |val| val } + values = entity.an_object.map { |key, val| val } expect(values[0]).to eq("Foobar") expect(values[1]).to eq(22) expect(values[2]).to be_a(BooticClient::Entity::PropertySet) From 3b9fde4bd9c0dd3f013a7cc18515e823be0fe52b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Fri, 1 May 2020 00:50:04 -0400 Subject: [PATCH 09/26] Map #as_json to #to_hash in Entity and PropertyHash --- lib/bootic_client/entity.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index edd6bb6..ebf0238 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -49,6 +49,8 @@ def to_hash @attrs end + alias_method :as_json, :to_hash + def [](key) has_property?(key) ? properties.get(key) : entities.get(key) end @@ -138,6 +140,8 @@ def to_hash @attrs end + alias_method :as_json, :to_hash + def dig(*keys) @attrs.dig(*keys) end From 5465be306906d9ea08d93e91e43f9af60816ee49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Fri, 1 May 2020 00:53:07 -0400 Subject: [PATCH 10/26] Duh, as_json receives an options argument --- lib/bootic_client/entity.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index ebf0238..6ae57de 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -49,7 +49,9 @@ def to_hash @attrs end - alias_method :as_json, :to_hash + def as_json(opts = {}) + to_hash + end def [](key) has_property?(key) ? properties.get(key) : entities.get(key) @@ -140,7 +142,9 @@ def to_hash @attrs end - alias_method :as_json, :to_hash + def as_json(opts = {}) + to_hash + end def dig(*keys) @attrs.dig(*keys) From d57d96236d61f8e6fee6906ce6580075cfa9cfce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Fri, 1 May 2020 01:52:18 -0400 Subject: [PATCH 11/26] Avoid initializing embedded items immediately on Entity initializiation (as part of iterable? check) --- lib/bootic_client/entity.rb | 59 +++++++++++++++++++++++++++++-------- spec/entity_spec.rb | 15 +++++----- 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index 6ae57de..f550442 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -5,7 +5,7 @@ module EnumerableEntity include Enumerable def each(&block) - entities.get(:items).each &block + entities.get(:items).each(&block) end def full_set @@ -32,7 +32,7 @@ def self.wrap(obj, client: nil, top: nil) when Hash new(obj, client, top: top) when Array - obj.map { |e| wrap(e, client: client, top: top) } + EntityArray.new(obj, client, top) else obj end @@ -128,7 +128,47 @@ def curies end def iterable? - entities.has?(:items) && entities.get(:items).respond_to?(:each) + entities.has?(:items) && entities.get(:items).is_a?(EntityArray) + end + + class EntityArray + include Enumerable + + attr_reader :size + + def initialize(arr, client, top) + @arr, @client, @top = arr, client, top + @cache = {} + end + + def length + @arr.length + end + + alias_method :size, :length + alias_method :count, :length + + # def first + # get(0) + # end + + def last + get(length-1) + end + + def [](index) + get(index) + end + + def each(&block) + return enum_for(:each) unless block_given? + + length.times { |i| yield get(i) } + end + + def get(index) + @cache[index] ||= Entity.wrap(@arr[index], client: @client, top: @top) + end end class PropertySet @@ -136,6 +176,7 @@ class PropertySet def initialize(attrs) @attrs = stringify_keys(attrs || {}) + @cache = {} end def to_hash @@ -163,7 +204,7 @@ def [](key) end def get(key) - cache[key.to_s] ||= wrap(@attrs[key.to_s]) + @cache[key.to_s] ||= wrap(@attrs[key.to_s]) end def each(&block) @@ -183,12 +224,6 @@ def wrap(value) end end - private - - def cache - @cache ||= {} - end - def method_missing(name, *args, &block) if has?(name) get(name) @@ -215,7 +250,7 @@ def initialize(attrs, client, top) end def get(key) - cache[key.to_s] ||= Entity.wrap(@attrs[key.to_s], client: @client, top: @top) + @cache[key.to_s] ||= Entity.wrap(@attrs[key.to_s], client: @client, top: @top) end end @@ -232,7 +267,7 @@ def has?(key) def get(key) return if key.to_s == CURIES_REL - cache[key.to_s] ||= begin + @cache[key.to_s] ||= begin key = key.to_s obj = @attrs[key] diff --git a/spec/entity_spec.rb b/spec/entity_spec.rb index af77f4a..e558ace 100644 --- a/spec/entity_spec.rb +++ b/spec/entity_spec.rb @@ -105,13 +105,13 @@ it 'responds to #[]' do expect(entity[:total_items]).to eql(10) - expect(entity[:items]).to be_a(Array) + expect(entity[:items]).to be_a(BooticClient::Entity::EntityArray) expect(entity[:foobar]).to eql(nil) end it 'responds to #try (same behaviour as [])' do expect(entity.try(:total_items)).to eql(10) - expect(entity.try(:items)).to be_a(Array) + expect(entity.try(:items)).to be_a(BooticClient::Entity::EntityArray) expect(entity.try(:foobar)).to eql(nil) end @@ -124,12 +124,12 @@ describe 'embedded entities' do it 'has a #entities object' do - expect(entity.entities[:items]).to be_a(Array) + expect(entity.entities[:items]).to be_a(BooticClient::Entity::EntityArray) expect(entity.entities[:items].first.entities[:shop]).to be_kind_of(BooticClient::Entity) end it 'are exposed like normal attributes' do - expect(entity.items).to be_kind_of(Array) + expect(entity.items).to be_kind_of(BooticClient::Entity::EntityArray) entity.items.first.tap do |product| expect(product).to be_kind_of(BooticClient::Entity) expect(product.title).to eql('iPhone 4') @@ -212,18 +212,19 @@ end describe 'iterating' do - it 'is an enumerable if it is a list' do + it 'is an enumerable if it contains embedded items' do prods = [] - entity.each{|pr| prods << pr} + entity.each { |pr| prods << pr } expect(prods).to match_array(entity.items) expect(entity.map{|pr| pr}).to match_array(entity.items) expect(entity.reduce(0){|sum,e| sum + e.price.to_i}).to eql(24687) expect(entity.each).to be_kind_of(Enumerator) end - it 'is not treated as an array if not a list' do + it 'does not respond to each if no embedded items' do ent = BooticClient::Entity.new({'foo' => 'bar'}, client) expect(ent).not_to respond_to(:each) + # expect { ent.each { |i| } }.to raise_error(NoMethodError) end end From b038d7922822a35c019cb5b609d6832caf470cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Fri, 1 May 2020 11:29:33 -0400 Subject: [PATCH 12/26] Make EntityArray inherit from Array so we get .empty? et-al when using ActiveSupport --- lib/bootic_client/entity.rb | 30 ++++++++++-------------------- spec/entity_spec.rb | 9 +++++++-- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index f550442..1c072b3 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -131,43 +131,33 @@ def iterable? entities.has?(:items) && entities.get(:items).is_a?(EntityArray) end - class EntityArray - include Enumerable - - attr_reader :size + class EntityArray < Array - def initialize(arr, client, top) - @arr, @client, @top = arr, client, top + def initialize(items, client, top) + super(items) + @client, @top = client, top @cache = {} end - def length - @arr.length + def first + self[0] end - alias_method :size, :length - alias_method :count, :length - - # def first - # get(0) - # end - def last - get(length-1) + self[length-1] end def [](index) - get(index) + @cache[index] ||= Entity.wrap(super, client: @client, top: @top) end def each(&block) return enum_for(:each) unless block_given? - - length.times { |i| yield get(i) } + length.times { |i| yield self[i] } end def get(index) - @cache[index] ||= Entity.wrap(@arr[index], client: @client, top: @top) + self[index] end end diff --git a/spec/entity_spec.rb b/spec/entity_spec.rb index e558ace..f42ff92 100644 --- a/spec/entity_spec.rb +++ b/spec/entity_spec.rb @@ -215,8 +215,13 @@ it 'is an enumerable if it contains embedded items' do prods = [] entity.each { |pr| prods << pr } - expect(prods).to match_array(entity.items) - expect(entity.map{|pr| pr}).to match_array(entity.items) + expect(prods[0]).to eq(entity.items[0]) + expect(prods[1]).to eq(entity.items[1]) + + mapped = entity.map{|pr| pr} + expect(mapped[0]).to eq(entity.items[0]) + expect(mapped[1]).to eq(entity.items[1]) + expect(entity.reduce(0){|sum,e| sum + e.price.to_i}).to eql(24687) expect(entity.each).to be_kind_of(Enumerator) end From 1bdc009c2be8dc4a5efb8ba9d1c32cb788281bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Fri, 1 May 2020 11:51:37 -0400 Subject: [PATCH 13/26] EntityArray: Revert to previous behaviour, but forward some methods to @items directly --- lib/bootic_client/entity.rb | 54 +++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index 1c072b3..1f52711 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -131,34 +131,42 @@ def iterable? entities.has?(:items) && entities.get(:items).is_a?(EntityArray) end - class EntityArray < Array + class EntityArray + include Enumerable + extend Forwardable def initialize(items, client, top) - super(items) + @items = items @client, @top = client, top @cache = {} end - def first - self[0] + def_instance_delegators :@items, :count, :size, :length, :empty?, :any? + + def inspect + %(#<#{self.class.name} length: #{length}]>) end + # def first + # self[0] + # end + def last self[length-1] end def [](index) - @cache[index] ||= Entity.wrap(super, client: @client, top: @top) + @cache[index] ||= Entity.wrap(@items[index], client: @client, top: @top) + end + + def get(index) + self[index] end def each(&block) return enum_for(:each) unless block_given? length.times { |i| yield self[i] } end - - def get(index) - self[index] - end end class PropertySet @@ -169,6 +177,18 @@ def initialize(attrs) @cache = {} end + def keys + @keys ||= @attrs.keys + end + + def has?(key) + @attrs.has_key?(key.to_s) + end + + def inspect + %(#<#{self.class.name} properties: [#{keys.join(', ')}]>) + end + def to_hash @attrs end @@ -181,14 +201,6 @@ def dig(*keys) @attrs.dig(*keys) end - def has?(key) - @attrs.has_key?(key.to_s) - end - - def keys - @keys ||= @attrs.keys - end - def [](key) get(key) end @@ -239,6 +251,10 @@ def initialize(attrs, client, top) @client, @top = client, top end + def inspect + %(#<#{self.class.name} entities: [#{keys.join(', ')}]>) + end + def get(key) @cache[key.to_s] ||= Entity.wrap(@attrs[key.to_s], client: @client, top: @top) end @@ -250,6 +266,10 @@ def initialize(attrs, client, top, curies) @curies = curies end + def inspect + %(#<#{self.class.name} rels: [#{keys.join(', ')}]>) + end + def has?(key) super || @attrs.has_key?("#{CURIE_NS}:#{key}") end From 426cca76cca9ff57f926c38adb76c9bb1b4e7c19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Fri, 1 May 2020 11:52:17 -0400 Subject: [PATCH 14/26] Rename 'props' to 'properties' and 'rels' to 'relations' for clarity. Alias old methods nontheless --- lib/bootic_client/entity.rb | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index 1f52711..3322a32 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -68,21 +68,25 @@ def can?(rel_name) end def inspect - %(#<#{self.class.name} props: [#{properties.keys.join(', ')}] rels: [#{rels.keys.join(', ')}] entities: [#{entities.keys.join(', ')}]>) + %(#<#{self.class.name} properties: [#{properties.keys.join(', ')}] relations: [#{rels.keys.join(', ')}] entities: [#{entities.keys.join(', ')}]>) end def properties @properties ||= PropertySet.new(attrs.select { |k,v| !(k =~ SPECIAL_PROP_EXP) }) end + alias_method :props, :properties + def entities @entities ||= EntitySet.new(attrs.fetch('_embedded', {}), client, top) end - def rels - @rels ||= RelationSet.new(attrs.fetch('_links', {}), client, top, curies) + def relations + @relations ||= RelationSet.new(attrs.fetch('_links', {}), client, top, curies) end + alias_method :rels, :relations + def links @links ||= attrs.fetch('_links', {}) end From d1002d121969ec0107e0e1edb9207b1d7bc74c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Mon, 4 May 2020 10:22:29 -0400 Subject: [PATCH 15/26] Overwrite PropertySet#count to avoid breaking behaviour of some entities (OrdersHistogram entries) --- lib/bootic_client/entity.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index 3322a32..74490cd 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -1,4 +1,5 @@ -require "bootic_client/relation" +require 'bootic_client/relation' +require 'forwardable' module BooticClient module EnumerableEntity @@ -181,6 +182,11 @@ def initialize(attrs) @cache = {} end + # overwrite Enumerable#count because some Entities have this prop + def count + get('count') or raise NoMethodError, "undefined method `count` for #{self.inspect}" + end + def keys @keys ||= @attrs.keys end From f9545a89f4bde3dce0e02ee6b5e602b3a6e603d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Mon, 4 May 2020 10:22:40 -0400 Subject: [PATCH 16/26] Use require relative in main require for easier local testing --- lib/bootic_client.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/bootic_client.rb b/lib/bootic_client.rb index ae1bd8d..ffc1fff 100644 --- a/lib/bootic_client.rb +++ b/lib/bootic_client.rb @@ -1,9 +1,9 @@ require 'logger' -require "bootic_client/version" -require "bootic_client/entity" -require "bootic_client/relation" -require "bootic_client/client" -require "bootic_client/configuration" +require_relative './bootic_client/version' +require_relative './bootic_client/entity' +require_relative './bootic_client/relation' +require_relative './bootic_client/client' +require_relative './bootic_client/configuration' module BooticClient class << self From 7cf2d0173ac0bff8b4fac048662d9d55d89440f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Tue, 5 May 2020 21:10:20 -0400 Subject: [PATCH 17/26] EntityArray: Don't forward #any? to @items directly as we'd lose the enumerable/block behaviour --- lib/bootic_client/entity.rb | 2 +- spec/entity_spec.rb | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index 74490cd..151b4dd 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -146,7 +146,7 @@ def initialize(items, client, top) @cache = {} end - def_instance_delegators :@items, :count, :size, :length, :empty?, :any? + def_instance_delegators :@items, :count, :size, :length, :empty? def inspect %(#<#{self.class.name} length: #{length}]>) diff --git a/spec/entity_spec.rb b/spec/entity_spec.rb index f42ff92..42a517e 100644 --- a/spec/entity_spec.rb +++ b/spec/entity_spec.rb @@ -128,8 +128,19 @@ expect(entity.entities[:items].first.entities[:shop]).to be_kind_of(BooticClient::Entity) end + it '#items is a enumerable object' do + items = entity.entities[:items] + expect(items).to be_a(Enumerable) + expect(items.any?).to eq(true) + + res = items.any? do |item| + expect(item).to be_a(BooticClient::Entity) + item.title == 'iPhone 4' + end + expect(res).to be(true) + end + it 'are exposed like normal attributes' do - expect(entity.items).to be_kind_of(BooticClient::Entity::EntityArray) entity.items.first.tap do |product| expect(product).to be_kind_of(BooticClient::Entity) expect(product.title).to eql('iPhone 4') From 6c0a1bf12da974c3af05fd5bb648f3025b6e8df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Thu, 7 May 2020 11:40:43 -0400 Subject: [PATCH 18/26] Allow method? access for boolean properties --- lib/bootic_client/entity.rb | 28 ++++++++++++++++++++++------ spec/entity_spec.rb | 10 +++++++++- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index 151b4dd..f09dab9 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -65,7 +65,7 @@ def has?(prop_name) end def can?(rel_name) - has_rel? rel_name + has_rel?(rel_name) end def inspect @@ -192,7 +192,7 @@ def keys end def has?(key) - @attrs.has_key?(key.to_s) + q = has_key?(key.to_s) || !!has_boolean?(key.to_s) end def inspect @@ -216,6 +216,10 @@ def [](key) end def get(key) + if !has_key?(key.to_s) && found = has_boolean?(key.to_s) + key = found + end + @cache[key.to_s] ||= wrap(@attrs[key.to_s]) end @@ -237,19 +241,31 @@ def wrap(value) end def method_missing(name, *args, &block) - if has?(name) + if has?(name.to_s) get(name) else super end end + def has_key?(key) + @attrs.has_key?(key) + end + + def has_boolean?(key) + if key[key.size-1] == '?' and key = key.chomp('?') + return key if is_boolean?(key) + end + end + + def is_boolean?(key) + @attrs[key].is_a?(TrueClass) || @attrs[key].is_a?(FalseClass) + end + # def all # keys.map { |k| get(key) } # end - private - def stringify_keys(hash) hash.inject({}) { |memo,(k,v)| memo[k.to_s] = v; memo } end @@ -277,7 +293,7 @@ def initialize(attrs, client, top, curies) end def inspect - %(#<#{self.class.name} rels: [#{keys.join(', ')}]>) + %(#<#{self.class.name} relations: [#{keys.join(', ')}]>) end def has?(key) diff --git a/spec/entity_spec.rb b/spec/entity_spec.rb index 42a517e..fe41c5a 100644 --- a/spec/entity_spec.rb +++ b/spec/entity_spec.rb @@ -159,9 +159,17 @@ end end - it 'includes FALSE values' do + it 'includes FALSE values, and allows querying with ?' do expect(entity.items.first.published).to be false + expect(entity.items.first.published?).to be false + expect(entity.items.last.published).to be true + expect(entity.items.last.published?).to be true + + expect(entity.items.first.has?(:published?)).to be true + expect(entity.items.first.has?(:published)).to be true + expect(entity.items.last.has?(:published)).to be true + expect(entity.items.last.has?(:published?)).to be true end end #/ embedded entities From f5b9ff073299fb6159f7365b033b5be80a47ba2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Thu, 13 Aug 2020 17:06:21 -0400 Subject: [PATCH 19/26] Retry requests on connection failure, and allow setting open/read timeout via options --- lib/bootic_client/client.rb | 11 +++++++++-- lib/bootic_client/strategies/oauth2_strategy.rb | 4 ++-- lib/bootic_client/strategies/strategy.rb | 14 +++++++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/lib/bootic_client/client.rb b/lib/bootic_client/client.rb index f78ad3a..6076287 100644 --- a/lib/bootic_client/client.rb +++ b/lib/bootic_client/client.rb @@ -79,9 +79,16 @@ def self.load(string) private + DEFAULT_TIMEOUT = 20.freeze # seconds + def conn(&block) - @conn ||= Faraday.new do |f| - cache_options = {serializer: SafeCacheSerializer, shared_cache: false, store: options[:cache_store]} + request_opts = { + timeout: options[:timeout] || DEFAULT_TIMEOUT, # both read/open timeout + open_timeout: options[:open_timeout] || DEFAULT_TIMEOUT # only open timeout + } + + @conn ||= Faraday.new(request: request_opts) do |f| + cache_options = { serializer: SafeCacheSerializer, shared_cache: false, store: options[:cache_store] } cache_options[:logger] = options[:logger] if options[:logging] f.use :http_cache, cache_options diff --git a/lib/bootic_client/strategies/oauth2_strategy.rb b/lib/bootic_client/strategies/oauth2_strategy.rb index 90954e6..0228f2d 100644 --- a/lib/bootic_client/strategies/oauth2_strategy.rb +++ b/lib/bootic_client/strategies/oauth2_strategy.rb @@ -30,10 +30,10 @@ def request_headers def retryable(&block) begin - yield + super rescue AuthorizationError => e update_token! - yield + super end end diff --git a/lib/bootic_client/strategies/strategy.rb b/lib/bootic_client/strategies/strategy.rb index 1f5af4a..b2bf4ca 100644 --- a/lib/bootic_client/strategies/strategy.rb +++ b/lib/bootic_client/strategies/strategy.rb @@ -38,6 +38,8 @@ def inspect attr_reader :config, :on_new_token + MAX_RETRIES = 3 + def validate! # Overwrite in sub classes # to raise ArgumentErrors on @@ -64,7 +66,17 @@ def pre_flight # end # def retryable(&block) - yield + begin + retries ||= 0 + yield + rescue Faraday::ConnectionFailed => e + if (retries += 1) < MAX_RETRIES + # puts "Retrying request, attempt #{retries}" + retry + else + raise + end + end end # Noop. Merge these headers into every request. From d7db068b20642fb1a65625bfe398c1b87cf6a22d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Thu, 13 Aug 2020 17:24:47 -0400 Subject: [PATCH 20/26] Move retry logic to client.rb --- lib/bootic_client/client.rb | 14 ++++++++++-- lib/bootic_client/strategies/strategy.rb | 14 +----------- spec/client_spec.rb | 29 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/lib/bootic_client/client.rb b/lib/bootic_client/client.rb index 6076287..be0d884 100644 --- a/lib/bootic_client/client.rb +++ b/lib/bootic_client/client.rb @@ -83,8 +83,8 @@ def self.load(string) def conn(&block) request_opts = { - timeout: options[:timeout] || DEFAULT_TIMEOUT, # both read/open timeout - open_timeout: options[:open_timeout] || DEFAULT_TIMEOUT # only open timeout + timeout: (options[:timeout] || DEFAULT_TIMEOUT).to_i, # both read/open timeout + open_timeout: (options[:open_timeout] || DEFAULT_TIMEOUT).to_i # only open timeout } @conn ||= Faraday.new(request: request_opts) do |f| @@ -107,6 +107,8 @@ def request_headers end def validated_request!(verb, href, &block) + retries ||= 0 + resp = conn.send(verb) do |req| req.url href req.headers.update request_headers @@ -115,6 +117,14 @@ def validated_request!(verb, href, &block) raise_if_invalid! resp resp + + rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e + if (retries += 1) < 3 # max retries + puts "Got #{e.class} error, attempt #{retries}, retrying..." + retry + else + raise + end end def raise_if_invalid!(resp) diff --git a/lib/bootic_client/strategies/strategy.rb b/lib/bootic_client/strategies/strategy.rb index b2bf4ca..1f5af4a 100644 --- a/lib/bootic_client/strategies/strategy.rb +++ b/lib/bootic_client/strategies/strategy.rb @@ -38,8 +38,6 @@ def inspect attr_reader :config, :on_new_token - MAX_RETRIES = 3 - def validate! # Overwrite in sub classes # to raise ArgumentErrors on @@ -66,17 +64,7 @@ def pre_flight # end # def retryable(&block) - begin - retries ||= 0 - yield - rescue Faraday::ConnectionFailed => e - if (retries += 1) < MAX_RETRIES - # puts "Retrying request, attempt #{retries}" - retry - else - raise - end - end + yield end # Noop. Merge these headers into every request. diff --git a/spec/client_spec.rb b/spec/client_spec.rb index d8dcdce..db98955 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -129,6 +129,35 @@ def assert_successful_response(response) end end + context 'timeouts' do + it 'triggers a new request' do + client = described_class.new(timeout: '60') + req = stub_request(:get, root_url) + .to_timeout + + expect do + client.get(root_url, {}, request_headers) + end.to raise_error(Faraday::TimeoutError) + + expect(req).to have_been_requested.times(3) + end + + it 'can be configured' do + expect(Faraday).to receive(:new).with(request: { + open_timeout: 20, + timeout: 60 + }).and_call_original + + client = described_class.new(timeout: '60') + + req = stub_request(:get, root_url) + .to_return(status: 200, body: JSON.dump(root_data), headers: response_headers) + + client.get(root_url, {}, request_headers) + expect(req).to have_been_requested + end + end + context 'errors' do describe '500 Server error' do before do From 194e28fea6e357c4b25083744f3b64621a03ac83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Thu, 2 Feb 2023 13:31:27 -0300 Subject: [PATCH 21/26] Alias to_h to to_hash in entity properties --- lib/bootic_client/entity.rb | 2 ++ spec/entity_spec.rb | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index b93bbb0..5ddb3b4 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -205,6 +205,8 @@ def to_hash @attrs end + alias_method :to_h, :to_hash + def as_json(opts = {}) to_hash end diff --git a/spec/entity_spec.rb b/spec/entity_spec.rb index fe41c5a..24c072c 100644 --- a/spec/entity_spec.rb +++ b/spec/entity_spec.rb @@ -83,6 +83,8 @@ expect(entity.an_object.another_object.foo).to eq 'bar' expect(entity.an_object.dig('another_object', 'foo')).to eq('bar') + expect(entity.an_object.to_hash).to eq({"name"=>"Foobar", "age"=>22, "another_object"=>{"foo"=>"bar"}}) + expect(entity.an_object.to_h).to eq({"name"=>"Foobar", "age"=>22, "another_object"=>{"foo"=>"bar"}}) end it 'allows enumerating over property sets' do @@ -123,6 +125,13 @@ describe 'embedded entities' do + it 'is a EntitySet' do + expect(entity.entities).to be_a(BooticClient::Entity::EntitySet) + expect(entity.entities.to_hash).to eq({ + "items"=> [{"title"=>"iPhone 4", "price"=>12345, "published"=>false, "_links"=>{"self"=>{:href=>"/products/iphone4"}, "btc:delete_product"=>{"href"=>"/products/12345"}}, "_embedded"=>{"shop"=>{"name"=>"Acme"}}}, {"title"=>"iPhone 5", "price"=>12342, "published"=>true, "_links"=>{"self"=>{:href=>"/products/iphone5"}}, "_embedded"=>{"shop"=>{"name"=>"Apple"}}}] + }) + end + it 'has a #entities object' do expect(entity.entities[:items]).to be_a(BooticClient::Entity::EntityArray) expect(entity.entities[:items].first.entities[:shop]).to be_kind_of(BooticClient::Entity) @@ -131,6 +140,7 @@ it '#items is a enumerable object' do items = entity.entities[:items] expect(items).to be_a(Enumerable) + expect(items.to_a).to be_a(Array) expect(items.any?).to eq(true) res = items.any? do |item| From c035f5ad6c44edab10b0264d406d337bfd77397f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Tue, 23 May 2023 17:48:59 -0400 Subject: [PATCH 22/26] Alias to_hash to to_h in Entity --- lib/bootic_client/entity.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index 5ddb3b4..d495587 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -52,6 +52,8 @@ def to_hash @attrs end + alias_method :to_h, :to_hash + def as_json(opts = {}) to_hash end From 975835450eff238e36b4566bfc2b23c42cd63187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Tue, 23 May 2023 17:49:07 -0400 Subject: [PATCH 23/26] Change && for and --- lib/bootic_client/entity.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index d495587..7eaa2a6 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -222,7 +222,7 @@ def [](key) end def get(key) - if !has_key?(key.to_s) && found = has_boolean?(key.to_s) + if !has_key?(key.to_s) and found = has_boolean?(key.to_s) key = found end From d938f0888dd98ff277fe87ca1deb843c713a68f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Fri, 17 Apr 2026 10:36:00 -0400 Subject: [PATCH 24/26] Improve memory usage and client robustness Potential memory leaks: - Entity and Relation now hold a WeakRef to the client/strategy, so entity graphs retained by application code (caches, sessions) no longer prevent the Strategy, its Faraday connection pool, and auth credentials from being garbage collected. A clear RuntimeError is raised if a link is followed after the strategy has been GC'd. - Configuration#logger now memoises with ||= instead of ||, preventing a fresh Logger from being allocated on every call when no logger is set. - Strategy no longer allocates an empty Proc.new{} when no on_new_token block is given; on_new_token is nil and callers use &.call. Code improvements: - Oauth2Strategy#retryable: remove redundant begin/end wrapper. - Replace default Faraday::HttpCache::MemoryStore (unbounded) with BoundedMemoryStore, which evicts the oldest entry once the cache reaches 500 entries, capping in-process memory growth in long-running servers. New features: - Client#close: nilifies the memoised Faraday connection so it can be GC'd. - Client accepts :timeout and :open_timeout options and forwards them to Faraday, making request timeouts configurable without monkey-patching. - TooManyRequestsError (HTTP 429) added to the error hierarchy so callers can rescue rate-limit responses distinctly from other transport --- bootic_client.gemspec | 4 + lib/bootic_client/client.rb | 28 +++++- lib/bootic_client/configuration.rb | 2 +- lib/bootic_client/entity.rb | 14 ++- lib/bootic_client/errors.rb | 1 + lib/bootic_client/relation.rb | 15 ++- .../strategies/oauth2_strategy.rb | 12 +-- lib/bootic_client/strategies/strategy.rb | 2 +- spec/bounded_memory_store_spec.rb | 91 +++++++++++++++++++ spec/client_spec.rb | 45 +++++++++ spec/configuration_spec.rb | 18 ++++ spec/entity_spec.rb | 42 +++++++++ spec/relation_spec.rb | 18 ++++ 13 files changed, 278 insertions(+), 14 deletions(-) create mode 100644 spec/bounded_memory_store_spec.rb diff --git a/bootic_client.gemspec b/bootic_client.gemspec index 4bb1c32..ed917bc 100644 --- a/bootic_client.gemspec +++ b/bootic_client.gemspec @@ -17,6 +17,9 @@ Gem::Specification.new do |spec| spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ["lib"] + spec.add_dependency "ostruct" + spec.add_dependency "base64" + spec.add_dependency "bigdecimal" spec.add_dependency "faraday", '~> 2.2' spec.add_dependency 'faraday-net_http_persistent', '~> 2.0' spec.add_dependency "uri_template", '~> 0.7' @@ -24,6 +27,7 @@ Gem::Specification.new do |spec| spec.add_dependency "net-http-persistent", '~> 4' spec.add_dependency "oauth2", "~> 1.4" + spec.add_development_dependency 'rexml' spec.add_development_dependency "rake" spec.add_development_dependency "rspec", "3.5.0" spec.add_development_dependency "jwt", "~> 1.5" diff --git a/lib/bootic_client/client.rb b/lib/bootic_client/client.rb index c2437bf..71467a7 100644 --- a/lib/bootic_client/client.rb +++ b/lib/bootic_client/client.rb @@ -22,7 +22,7 @@ def initialize(options = {}, &block) user_agent: USER_AGENT }.merge(options.dup) - @options[:cache_store] = @options[:cache_store] || Faraday::HttpCache::MemoryStore.new + @options[:cache_store] ||= BoundedMemoryStore.new conn &block if block_given? end @@ -61,6 +61,29 @@ def delete(href, _ = {}, headers = {}) end end + def close + @conn = nil + end + + # In-memory cache with a hard upper bound on entries to prevent unbounded growth. + # Evicts the oldest entry when the limit is reached. + class BoundedMemoryStore + DEFAULT_MAX_SIZE = 500 + + def initialize(max_size: DEFAULT_MAX_SIZE) + @store = {} + @max_size = max_size + end + + def read(key) = @store[key] + def write(key, value) + @store.delete(@store.keys.first) if !@store.key?(key) && @store.size >= @max_size + @store[key] = value + end + def delete(key) = @store.delete(key) + def exist?(key) = @store.key?(key) + end + class SafeCacheSerializer PREFIX = '__booticclient__base64__:'.freeze PREFIX_EXP = %r{^#{PREFIX}}.freeze @@ -88,6 +111,8 @@ def conn(&block) f.use :http_cache, **cache_options f.response :logger, options[:logger] if options[:logging] + f.options.timeout = options[:timeout] if options[:timeout] + f.options.open_timeout = options[:open_timeout] if options[:open_timeout] yield f if block_given? f.adapter *Array(options[:faraday_adapter]) end @@ -114,6 +139,7 @@ def validated_request!(verb, href, &block) def raise_if_invalid!(resp, url = nil) raise ServerError.new("Server Error", url) if resp.status > 499 + raise TooManyRequestsError.new("Too Many Requests", url) if resp.status == 429 raise NotFoundError.new("Not Found", url) if resp.status == 404 raise UnauthorizedError.new("Unauthorized Request", url) if resp.status == 401 raise AccessForbiddenError.new("Access Forbidden", url) if resp.status == 403 diff --git a/lib/bootic_client/configuration.rb b/lib/bootic_client/configuration.rb index b275619..57dd272 100644 --- a/lib/bootic_client/configuration.rb +++ b/lib/bootic_client/configuration.rb @@ -52,7 +52,7 @@ def api_root end def logger - @logger || ::Logger.new(STDOUT) + @logger ||= ::Logger.new(STDOUT) end def response_handlers diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index e695b3e..3935a6a 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "bootic_client/relation" +require "weakref" module BooticClient module EnumerableEntity @@ -38,7 +39,8 @@ class Entity def initialize(attrs, client, top: self) @attrs = attrs.kind_of?(Hash) ? attrs : {} - @client, @top = client, top + @client = client ? WeakRef.new(client) : nil + @top = top build! self.extend EnumerableEntity if iterable? end @@ -137,7 +139,15 @@ def rels private - attr_reader :client, :top, :attrs + attr_reader :top, :attrs + + def client + return nil unless @client + @client.__getobj__ + rescue WeakRef::RefError + raise "BooticClient: the client for this entity has been garbage collected. " \ + "Hold a reference to your strategy/client for as long as you need to follow links." + end def iterable? has_entity?(:items) && entities[:items].respond_to?(:each) diff --git a/lib/bootic_client/errors.rb b/lib/bootic_client/errors.rb index 9bcc824..08fed0f 100644 --- a/lib/bootic_client/errors.rb +++ b/lib/bootic_client/errors.rb @@ -14,6 +14,7 @@ class NotFoundError < ServerError; end class AuthorizationError < ServerError; end class UnauthorizedError < AuthorizationError; end class AccessForbiddenError < AuthorizationError; end + class TooManyRequestsError < TransportError; end class ClientError < TransportError; end class InvalidURLError < ClientError; end end diff --git a/lib/bootic_client/relation.rb b/lib/bootic_client/relation.rb index d913f72..b1443e5 100644 --- a/lib/bootic_client/relation.rb +++ b/lib/bootic_client/relation.rb @@ -3,6 +3,7 @@ require "bootic_client/whiny_uri" require "bootic_client/entity" require 'ostruct' +require 'weakref' module BooticClient @@ -21,7 +22,8 @@ def complain_on_undeclared_params end def initialize(attrs, client, complain_on_undeclared_params: self.class.complain_on_undeclared_params) - @attrs, @client = attrs, client + @attrs = attrs + @client = client ? WeakRef.new(client) : nil @complain_on_undeclared_params = complain_on_undeclared_params end @@ -84,7 +86,16 @@ def self.expand(href, opts = {}) end protected - attr_reader :client, :attrs, :complain_on_undeclared_params + + attr_reader :attrs, :complain_on_undeclared_params + + def client + return nil unless @client + @client.__getobj__ + rescue WeakRef::RefError + raise "BooticClient: the client for this relation has been garbage collected. " \ + "Hold a reference to your strategy/client for as long as you need to follow links." + end def uri @uri ||= WhinyURI.new(href, complain_on_undeclared_params) diff --git a/lib/bootic_client/strategies/oauth2_strategy.rb b/lib/bootic_client/strategies/oauth2_strategy.rb index b21ca42..ea8510c 100644 --- a/lib/bootic_client/strategies/oauth2_strategy.rb +++ b/lib/bootic_client/strategies/oauth2_strategy.rb @@ -31,18 +31,16 @@ def request_headers end def retryable(&block) - begin - yield - rescue AuthorizationError => e - update_token! - yield - end + yield + rescue AuthorizationError + update_token! + yield end def update_token! new_token = get_token options[:access_token] = new_token - on_new_token.call new_token + on_new_token&.call(new_token) end def get_token diff --git a/lib/bootic_client/strategies/strategy.rb b/lib/bootic_client/strategies/strategy.rb index 920d241..62cbec4 100644 --- a/lib/bootic_client/strategies/strategy.rb +++ b/lib/bootic_client/strategies/strategy.rb @@ -7,7 +7,7 @@ class Strategy attr_reader :options def initialize(config, client_opts = {}, &on_new_token) - @config, @options, @on_new_token = config, client_opts, (on_new_token || Proc.new{}) + @config, @options, @on_new_token = config, client_opts, on_new_token raise ArgumentError, 'must include a Configuration object' unless config validate! end diff --git a/spec/bounded_memory_store_spec.rb b/spec/bounded_memory_store_spec.rb new file mode 100644 index 0000000..dea4316 --- /dev/null +++ b/spec/bounded_memory_store_spec.rb @@ -0,0 +1,91 @@ +require 'spec_helper' + +describe BooticClient::Client::BoundedMemoryStore do + subject(:store) { described_class.new(max_size: 3) } + + describe '#write and #read' do + it 'stores and retrieves values' do + store.write('a', 'alpha') + expect(store.read('a')).to eq 'alpha' + end + + it 'returns nil for missing keys' do + expect(store.read('missing')).to be_nil + end + end + + describe '#exist?' do + it 'returns true for a stored key' do + store.write('a', 'alpha') + expect(store.exist?('a')).to be true + end + + it 'returns false for an absent key' do + expect(store.exist?('nope')).to be false + end + end + + describe '#delete' do + it 'removes an entry' do + store.write('a', 'alpha') + store.delete('a') + expect(store.exist?('a')).to be false + end + end + + describe 'eviction when max_size is reached' do + it 'evicts the oldest entry when a new key is added beyond capacity' do + store.write('a', 'alpha') + store.write('b', 'beta') + store.write('c', 'gamma') + store.write('d', 'delta') + + expect(store.exist?('a')).to be false + expect(store.exist?('b')).to be true + expect(store.exist?('c')).to be true + expect(store.exist?('d')).to be true + end + + it 'evicts the second-oldest when the oldest slot has already been evicted' do + store.write('a', 'alpha') + store.write('b', 'beta') + store.write('c', 'gamma') + store.write('d', 'delta') # evicts 'a' + store.write('e', 'epsilon') # evicts 'b' + + expect(store.exist?('a')).to be false + expect(store.exist?('b')).to be false + expect(store.exist?('c')).to be true + expect(store.exist?('d')).to be true + expect(store.exist?('e')).to be true + end + + it 'does not evict when updating an existing key' do + store.write('a', 'alpha') + store.write('b', 'beta') + store.write('c', 'gamma') + store.write('a', 'updated') + + expect(store.exist?('a')).to be true + expect(store.exist?('b')).to be true + expect(store.exist?('c')).to be true + expect(store.read('a')).to eq 'updated' + end + + it 'never exceeds max_size' do + 100.times { |i| store.write("key#{i}", "val#{i}") } + count = (0...100).count { |i| store.exist?("key#{i}") } + expect(count).to eq 3 + end + end + + describe 'default max_size' do + it 'defaults to 500 entries' do + expect(described_class::DEFAULT_MAX_SIZE).to eq 500 + default_store = described_class.new + 501.times { |i| default_store.write("k#{i}", "v#{i}") } + count = (0...501).count { |i| default_store.exist?("k#{i}") } + expect(count).to eq 500 + end + end +end diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 57797f7..d790c41 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -205,10 +205,55 @@ def assert_successful_response(response) end end end + + describe '429 Too Many Requests' do + before do + stub_request(:get, root_url) + .to_return(status: 429, body: JSON.dump(message: 'Rate Limited'), headers: response_headers) + end + + it 'raises TooManyRequestsError' do + expect { + client.get(root_url) + }.to raise_error(BooticClient::TooManyRequestsError) + end + + it 'carries the request URL on the error' do + begin + client.get(root_url) + rescue BooticClient::TooManyRequestsError => e + expect(e.url).to eq("GET #{root_url}") + end + end + + it 'is not a subclass of ServerError' do + expect(BooticClient::TooManyRequestsError.ancestors).not_to include(BooticClient::ServerError) + end + end end end + describe '#close' do + before do + stub_request(:get, root_url) + .to_return(status: 200, body: JSON.dump(root_data), headers: response_headers) + end + + it 'resets the Faraday connection so it can be garbage collected' do + client.get(root_url) + conn_before = client.send(:conn) + client.close + conn_after = client.send(:conn) + expect(conn_before).not_to equal(conn_after) + end + + it 'allows making new requests after closing' do + client.close + expect { client.get(root_url) }.not_to raise_error + end + end + context 'HTTP verbs' do describe 'GET' do diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index f649e21..b206e3f 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -71,5 +71,23 @@ expect(config.response_handlers.to_a.first).not_to be_nil end end + + describe '#logger' do + it 'returns the same Logger instance on every call when no logger is configured' do + expect(config.logger).to equal(config.logger) + end + + it 'returns the configured logger when one has been set' do + custom = Logger.new(STDOUT) + config.logger = custom + expect(config.logger).to equal(custom) + end + + it 'does not allocate a new Logger on every call' do + first_call_id = config.logger.object_id + second_call_id = config.logger.object_id + expect(first_call_id).to eq(second_call_id) + end + end end end diff --git a/spec/entity_spec.rb b/spec/entity_spec.rb index 9229f51..bc4f440 100644 --- a/spec/entity_spec.rb +++ b/spec/entity_spec.rb @@ -275,4 +275,46 @@ end end + context 'memory leak prevention' do + let(:linked_payload) do + { + 'title' => 'Root', + '_links' => {'next' => {'href' => '/page/2'}}, + '_embedded' => {'items' => [{'title' => 'Child'}]} + } + end + + it 'stores client as a WeakRef so entity graphs do not pin the strategy in memory' do + entity = described_class.new(linked_payload, client) + expect(entity.instance_variable_get(:@client)).to be_a(WeakRef) + end + + it 'also wraps client as a WeakRef in embedded child entities' do + entity = described_class.new(linked_payload, client) + child = entity.entities[:items].first + expect(child.instance_variable_get(:@client)).to be_a(WeakRef) + end + + it 'raises a descriptive error when the client has been garbage collected and a link is followed' do + entity = described_class.new(linked_payload, Object.new) + dead_ref = entity.instance_variable_get(:@client) + allow(dead_ref).to receive(:__getobj__).and_raise(WeakRef::RefError) + expect { entity.next }.to raise_error(RuntimeError, /garbage collected/) + end + + it 'still exposes plain properties after the client is gone, because they are memoised at build time' do + entity = described_class.new(linked_payload, Object.new) + # warm the properties cache while the client is alive + _ = entity.properties + dead_ref = entity.instance_variable_get(:@client) + allow(dead_ref).to receive(:__getobj__).and_raise(WeakRef::RefError) + # reading already-memoised properties must not need the client + expect(entity.title).to eq 'Root' + end + + it 'accepts a nil client without error' do + expect { described_class.new(linked_payload, nil) }.not_to raise_error + end + end + end diff --git a/spec/relation_spec.rb b/spec/relation_spec.rb index fcf2b42..708f29c 100644 --- a/spec/relation_spec.rb +++ b/spec/relation_spec.rb @@ -125,4 +125,22 @@ end end end + + context 'memory leak prevention' do + it 'stores the client as a WeakRef so relations do not pin the strategy in memory' do + relation = described_class.new({'href' => '/foo'}, client) + expect(relation.instance_variable_get(:@client)).to be_a(WeakRef) + end + + it 'raises a descriptive error when the client has been garbage collected' do + relation = described_class.new({'href' => '/foo'}, Object.new) + dead_ref = relation.instance_variable_get(:@client) + allow(dead_ref).to receive(:__getobj__).and_raise(WeakRef::RefError) + expect { relation.run }.to raise_error(RuntimeError, /garbage collected/) + end + + it 'accepts a nil client without error' do + expect { described_class.new({'href' => '/foo'}, nil) }.not_to raise_error + end + end end From e97cad7cf1d0d595411459fa7e7dfb292f564cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Fri, 17 Apr 2026 10:46:33 -0400 Subject: [PATCH 25/26] Keep compatibility with older ruby versions --- lib/bootic_client/client.rb | 69 ++++++++++--------- lib/bootic_client/entity.rb | 45 ++++++------ .../strategies/oauth2_strategy.rb | 10 +-- 3 files changed, 63 insertions(+), 61 deletions(-) diff --git a/lib/bootic_client/client.rb b/lib/bootic_client/client.rb index 71467a7..b287565 100644 --- a/lib/bootic_client/client.rb +++ b/lib/bootic_client/client.rb @@ -3,15 +3,13 @@ require 'base64' require 'faraday' require 'faraday-http-cache' -require "bootic_client/errors" +require 'bootic_client/errors' require 'faraday/net_http_persistent' module BooticClient - class Client - - USER_AGENT = "[BooticClient v#{VERSION}] Ruby-#{RUBY_VERSION} - #{RUBY_PLATFORM}".freeze - JSON_MIME = 'application/json'.freeze + USER_AGENT = "[BooticClient v#{VERSION}] Ruby-#{RUBY_VERSION} - #{RUBY_PLATFORM}" + JSON_MIME = 'application/json' attr_reader :options @@ -24,7 +22,7 @@ def initialize(options = {}, &block) @options[:cache_store] ||= BoundedMemoryStore.new - conn &block if block_given? + conn(&block) if block_given? end def get(href, query = {}, headers = {}) @@ -75,18 +73,27 @@ def initialize(max_size: DEFAULT_MAX_SIZE) @max_size = max_size end - def read(key) = @store[key] + def read(key) + @store[key] + end + def write(key, value) @store.delete(@store.keys.first) if !@store.key?(key) && @store.size >= @max_size @store[key] = value end - def delete(key) = @store.delete(key) - def exist?(key) = @store.key?(key) + + def delete(key) + @store.delete(key) + end + + def exist?(key) + @store.key?(key) + end end class SafeCacheSerializer - PREFIX = '__booticclient__base64__:'.freeze - PREFIX_EXP = %r{^#{PREFIX}}.freeze + PREFIX = '__booticclient__base64__:' + PREFIX_EXP = /^#{PREFIX}/.freeze def self.dump(data) data[:body] = "#{PREFIX}#{Base64.strict_encode64(data[:body])}" if data[:body].is_a?(String) @@ -95,18 +102,16 @@ def self.dump(data) def self.load(string) data = JSON.load(string) - if data['body'] =~ PREFIX_EXP - data['body'] = Base64.strict_decode64(data['body'].sub(PREFIX, '')) - end + data['body'] = Base64.strict_decode64(data['body'].sub(PREFIX, '')) if data['body'] =~ PREFIX_EXP data end end private - def conn(&block) + def conn @conn ||= Faraday.new do |f| - cache_options = {serializer: SafeCacheSerializer, shared_cache: false, store: options[:cache_store]} + cache_options = { serializer: SafeCacheSerializer, shared_cache: false, store: options[:cache_store] } cache_options[:logger] = options[:logger] if options[:logging] f.use :http_cache, **cache_options @@ -114,7 +119,7 @@ def conn(&block) f.options.timeout = options[:timeout] if options[:timeout] f.options.open_timeout = options[:open_timeout] if options[:open_timeout] yield f if block_given? - f.adapter *Array(options[:faraday_adapter]) + f.adapter(*Array(options[:faraday_adapter])) end end @@ -126,7 +131,7 @@ def request_headers } end - def validated_request!(verb, href, &block) + def validated_request!(verb, href) resp = conn.send(verb) do |req| req.url href req.headers.update request_headers @@ -138,25 +143,25 @@ def validated_request!(verb, href, &block) end def raise_if_invalid!(resp, url = nil) - raise ServerError.new("Server Error", url) if resp.status > 499 - raise TooManyRequestsError.new("Too Many Requests", url) if resp.status == 429 - raise NotFoundError.new("Not Found", url) if resp.status == 404 - raise UnauthorizedError.new("Unauthorized Request", url) if resp.status == 401 - raise AccessForbiddenError.new("Access Forbidden", url) if resp.status == 403 + raise ServerError.new('Server Error', url) if resp.status > 499 + raise TooManyRequestsError.new('Too Many Requests', url) if resp.status == 429 + raise NotFoundError.new('Not Found', url) if resp.status == 404 + raise UnauthorizedError.new('Unauthorized Request', url) if resp.status == 401 + raise AccessForbiddenError.new('Access Forbidden', url) if resp.status == 403 end def sanitized(payload) - return payload unless payload.kind_of?(Hash) + return payload unless payload.is_a?(Hash) + payload.each_with_object({}) do |(k, v), memo| - memo[k] = if v.kind_of?(Hash) - sanitized v - elsif v.respond_to?(:read) - Base64.encode64 v.read - else - v - end + memo[k] = if v.is_a?(Hash) + sanitized v + elsif v.respond_to?(:read) + Base64.encode64 v.read + else + v + end end end end - end diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index 3935a6a..9db5634 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true -require "bootic_client/relation" -require "weakref" +require 'bootic_client/relation' +require 'weakref' module BooticClient module EnumerableEntity include Enumerable def each(&block) - self[:items].each &block + self[:items].each(&block) end def full_set @@ -18,6 +18,7 @@ def full_set loop do page.each { |item| yielder.yield(item) } raise StopIteration unless page.has_rel?(:next) + page = page.next if page.has?(:errors) # && page.errors.first.messages.first['cannot be higher'] # reached last page @@ -30,19 +31,18 @@ def full_set end class Entity - CURIE_EXP = /(.+):(.+)/.freeze - CURIES_REL = 'curies'.freeze + CURIES_REL = 'curies' SPECIAL_PROP_EXP = /^_.+/.freeze attr_reader :curies, :entities def initialize(attrs, client, top: self) - @attrs = attrs.kind_of?(Hash) ? attrs : {} + @attrs = attrs.is_a?(Hash) ? attrs : {} @client = client ? WeakRef.new(client) : nil @top = top build! - self.extend EnumerableEntity if iterable? + extend EnumerableEntity if iterable? end def to_hash @@ -67,7 +67,7 @@ def inspect end def properties - @properties ||= attrs.select{|k,v| !(k =~ SPECIAL_PROP_EXP)}.each_with_object({}) do |(k,v),memo| + @properties ||= attrs.select { |k, _v| !(k =~ SPECIAL_PROP_EXP) }.each_with_object({}) do |(k, v), memo| memo[k.to_sym] = Entity.wrap(v, client: client, top: top) end end @@ -81,7 +81,7 @@ def self.wrap(obj, client: nil, top: nil) when Hash new(obj, client, top: top) when Array - obj.map{|e| wrap(e, client: client, top: top)} + obj.map { |e| wrap(e, client: client, top: top) } else obj end @@ -103,7 +103,7 @@ def method_missing(name, *args, &block) end end - def respond_to_missing?(method_name, include_private = false) + def respond_to_missing?(method_name, _include_private = false) has?(method_name) end @@ -120,12 +120,12 @@ def has_rel?(prop_name) end def rels - @rels ||= ( + @rels ||= begin links = attrs.fetch('_links', {}) - links.each_with_object({}) do |(rel,rel_attrs),memo| + links.each_with_object({}) do |(rel, rel_attrs), memo| if rel =~ CURIE_EXP _, curie_namespace, rel = rel.split(CURIE_EXP) - if curie = curies.find{|c| c['name'] == curie_namespace} + if curie = curies.find { |c| c['name'] == curie_namespace } rel_attrs['docs'] = Relation.expand(curie['href'], rel: rel) end end @@ -134,7 +134,7 @@ def rels memo[rel.to_sym] = Relation.new(rel_attrs, client) end end - ) + end end private @@ -143,10 +143,11 @@ def rels def client return nil unless @client + @client.__getobj__ rescue WeakRef::RefError - raise "BooticClient: the client for this entity has been garbage collected. " \ - "Hold a reference to your strategy/client for as long as you need to follow links." + raise 'BooticClient: the client for this entity has been garbage collected. ' + + 'Hold a reference to your strategy/client for as long as you need to follow links.' end def iterable? @@ -156,12 +157,12 @@ def iterable? def build! @curies = top.links.fetch('curies', []) - @entities = attrs.fetch('_embedded', {}).each_with_object({}) do |(k,v),memo| - memo[k.to_sym] = if v.kind_of?(Array) - v.map{|ent_attrs| Entity.new(ent_attrs, client, top: top)} - else - Entity.new(v, client, top: top) - end + @entities = attrs.fetch('_embedded', {}).each_with_object({}) do |(k, v), memo| + memo[k.to_sym] = if v.is_a?(Array) + v.map { |ent_attrs| Entity.new(ent_attrs, client, top: top) } + else + Entity.new(v, client, top: top) + end end end end diff --git a/lib/bootic_client/strategies/oauth2_strategy.rb b/lib/bootic_client/strategies/oauth2_strategy.rb index ea8510c..f483f6b 100644 --- a/lib/bootic_client/strategies/oauth2_strategy.rb +++ b/lib/bootic_client/strategies/oauth2_strategy.rb @@ -5,9 +5,7 @@ module BooticClient module Strategies - class Oauth2Strategy < Strategy - def inspect %(#<#{self.class.name} cid: #{config.client_id} root: #{config.api_root} auth: #{config.auth_host}>) end @@ -30,7 +28,7 @@ def request_headers } end - def retryable(&block) + def retryable yield rescue AuthorizationError update_token! @@ -40,11 +38,11 @@ def retryable(&block) def update_token! new_token = get_token options[:access_token] = new_token - on_new_token&.call(new_token) + on_new_token.call(new_token) if on_new_token end def get_token - raise "Implement this in subclasses" + raise 'Implement this in subclasses' end def auth @@ -54,8 +52,6 @@ def auth site: config.auth_host ) end - end - end end From 95f5a31e05648622c4f5164dadef634cc4222b11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Pollak?= Date: Tue, 12 May 2026 14:07:25 -0400 Subject: [PATCH 26/26] Fixes --- lib/bootic_client/entity.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/bootic_client/entity.rb b/lib/bootic_client/entity.rb index 8d847d9..d121876 100644 --- a/lib/bootic_client/entity.rb +++ b/lib/bootic_client/entity.rb @@ -31,9 +31,10 @@ def full_set end class Entity - CURIE_EXP = /(.+):(.+)/.freeze + + CURIE_NS = 'btc' CURIES_REL = 'curies' - SPECIAL_PROP_EXP = /^_.+/.freeze + SPECIAL_PROP_EXP = /^_.+/ def self.wrap(obj, client: nil, top: nil) case obj @@ -210,7 +211,7 @@ def keys end def has?(key) - q = has_key?(key.to_s) || !!has_boolean?(key.to_s) + has_key?(key.to_s) || !!has_boolean?(key.to_s) end def inspect