From 7a832beebf5ec34fb95bd7378e26533d8f3fb42a Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Mon, 15 Sep 2025 12:43:54 -0400 Subject: [PATCH 01/22] Spitball an ApiError hierarchy. --- lib/square/cards/client.rb | 9 +++++---- lib/square/errors/api_error.rb | 27 +++++++++++++++++++++++++++ lib/square/errors/client_error.rb | 6 ++++++ lib/square/errors/redirect_error.rb | 6 ++++++ lib/square/errors/server_error.rb | 6 ++++++ 5 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 lib/square/errors/api_error.rb create mode 100644 lib/square/errors/client_error.rb create mode 100644 lib/square/errors/redirect_error.rb create mode 100644 lib/square/errors/server_error.rb diff --git a/lib/square/cards/client.rb b/lib/square/cards/client.rb index 07a0fa3e..f63e1aa0 100644 --- a/lib/square/cards/client.rb +++ b/lib/square/cards/client.rb @@ -24,11 +24,12 @@ def list(request_options: {}, **params) query: _query ) _response = @client.send(_request) - if _response.code >= "200" && _response.code < "300" - return Square::Types::ListCardsResponse.load(_response.body) + code = _response.code.to_i + if code.between?(200, 299) + Square::Types::ListCardsResponse.load(_response.body) + else + raise Square::Errors::ApiError.subclass_for_code(code, _response.body) end - - raise _response.body end # Adds a card on file to an existing merchant. diff --git a/lib/square/errors/api_error.rb b/lib/square/errors/api_error.rb new file mode 100644 index 00000000..f070e9ad --- /dev/null +++ b/lib/square/errors/api_error.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Square + module Errors + class ApiError < StandardError + attr_reader :code + + def initialize(msg, code:) + @code = code + super(msg) + end + + def self.subclass_for_code(code, msg) + case code + when 300..399 + RedirectError.new(_response.body, code: code) + when 400..499 + ClientError.new(_response.body, code: code) + when 500..599 + ServerError.new(_response.body, code: code) + else + ApiError.new(_response.body, code: code) + end + end + end + end +end diff --git a/lib/square/errors/client_error.rb b/lib/square/errors/client_error.rb new file mode 100644 index 00000000..35a753d5 --- /dev/null +++ b/lib/square/errors/client_error.rb @@ -0,0 +1,6 @@ +module Square + module Errors + class ClientError < ApiError + end + end +end diff --git a/lib/square/errors/redirect_error.rb b/lib/square/errors/redirect_error.rb new file mode 100644 index 00000000..0641fdf2 --- /dev/null +++ b/lib/square/errors/redirect_error.rb @@ -0,0 +1,6 @@ +module Square + module Errors + class RedirectError < ApiError + end + end +end diff --git a/lib/square/errors/server_error.rb b/lib/square/errors/server_error.rb new file mode 100644 index 00000000..551c9f15 --- /dev/null +++ b/lib/square/errors/server_error.rb @@ -0,0 +1,6 @@ +module Square + module Errors + class ServerError < ApiError + end + end +end From 53d4596f79ccc562115ea1dfca680911853ec4d7 Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Mon, 15 Sep 2025 13:43:40 -0400 Subject: [PATCH 02/22] Add more error types. --- lib/square/cards/client.rb | 3 ++- lib/square/errors/api_error.rb | 21 ++++++++++++++++----- lib/square/errors/client_error.rb | 9 +++++++++ lib/square/errors/server_error.rb | 3 +++ 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/lib/square/cards/client.rb b/lib/square/cards/client.rb index f63e1aa0..5cfb13f5 100644 --- a/lib/square/cards/client.rb +++ b/lib/square/cards/client.rb @@ -28,7 +28,8 @@ def list(request_options: {}, **params) if code.between?(200, 299) Square::Types::ListCardsResponse.load(_response.body) else - raise Square::Errors::ApiError.subclass_for_code(code, _response.body) + subclass = Square::Errors::ApiError.subclass_for_code(code) + raise subclass.new(_response.body, code: code) end end diff --git a/lib/square/errors/api_error.rb b/lib/square/errors/api_error.rb index f070e9ad..f3229a42 100644 --- a/lib/square/errors/api_error.rb +++ b/lib/square/errors/api_error.rb @@ -10,16 +10,27 @@ def initialize(msg, code:) super(msg) end - def self.subclass_for_code(code, msg) + # Returns the most appropriate error class for the given code. + # + # @return [Class] + def self.subclass_for_code(code) case code when 300..399 - RedirectError.new(_response.body, code: code) + RedirectError + when 401 + UnauthorizedError + when 403 + ForbiddenError + when 404 + NotFoundError when 400..499 - ClientError.new(_response.body, code: code) + ClientError + when 503 + ServiceUnavailableError when 500..599 - ServerError.new(_response.body, code: code) + ServerError else - ApiError.new(_response.body, code: code) + ApiError end end end diff --git a/lib/square/errors/client_error.rb b/lib/square/errors/client_error.rb index 35a753d5..0a356795 100644 --- a/lib/square/errors/client_error.rb +++ b/lib/square/errors/client_error.rb @@ -2,5 +2,14 @@ module Square module Errors class ClientError < ApiError end + + class UnauthorizedError < ClientError + end + + class ForbiddenError < ClientError + end + + class NotFoundError < ClientError + end end end diff --git a/lib/square/errors/server_error.rb b/lib/square/errors/server_error.rb index 551c9f15..6786cabf 100644 --- a/lib/square/errors/server_error.rb +++ b/lib/square/errors/server_error.rb @@ -2,5 +2,8 @@ module Square module Errors class ServerError < ApiError end + + class ServiceUnavailableError < ApiError + end end end From bc52ba8208452ac66263f0cea5d03cebd7abfd46 Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Mon, 15 Sep 2025 13:53:16 -0400 Subject: [PATCH 03/22] Add error handling for http timeouts. --- lib/square/cards/client.rb | 7 ++++++- lib/square/errors/timeout_error.rb | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 lib/square/errors/timeout_error.rb diff --git a/lib/square/cards/client.rb b/lib/square/cards/client.rb index 5cfb13f5..c1f20a29 100644 --- a/lib/square/cards/client.rb +++ b/lib/square/cards/client.rb @@ -23,7 +23,12 @@ def list(request_options: {}, **params) path: "v2/cards", query: _query ) - _response = @client.send(_request) + + begin + _response = @client.send(_request) + rescue Net::HTTPRequestTimeout + raise Square::Errors::TimeoutError + end code = _response.code.to_i if code.between?(200, 299) Square::Types::ListCardsResponse.load(_response.body) diff --git a/lib/square/errors/timeout_error.rb b/lib/square/errors/timeout_error.rb new file mode 100644 index 00000000..b59605c2 --- /dev/null +++ b/lib/square/errors/timeout_error.rb @@ -0,0 +1,6 @@ +module Square + module Errors + class TimeoutError < StandardError + end + end +end From 44b2afaf6861952e3d554275d31d092eae941f83 Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Mon, 15 Sep 2025 14:16:59 -0400 Subject: [PATCH 04/22] Actually, introduce an ApiError that's a superclass of all our errors. --- lib/square/errors/api_error.rb | 34 +------------------------- lib/square/errors/client_error.rb | 2 +- lib/square/errors/redirect_error.rb | 2 +- lib/square/errors/response_error.rb | 38 +++++++++++++++++++++++++++++ lib/square/errors/server_error.rb | 2 +- lib/square/errors/timeout_error.rb | 2 +- 6 files changed, 43 insertions(+), 37 deletions(-) create mode 100644 lib/square/errors/response_error.rb diff --git a/lib/square/errors/api_error.rb b/lib/square/errors/api_error.rb index f3229a42..bcd3fdd3 100644 --- a/lib/square/errors/api_error.rb +++ b/lib/square/errors/api_error.rb @@ -1,38 +1,6 @@ -# frozen_string_literal: true - module Square module Errors class ApiError < StandardError - attr_reader :code - - def initialize(msg, code:) - @code = code - super(msg) - end - - # Returns the most appropriate error class for the given code. - # - # @return [Class] - def self.subclass_for_code(code) - case code - when 300..399 - RedirectError - when 401 - UnauthorizedError - when 403 - ForbiddenError - when 404 - NotFoundError - when 400..499 - ClientError - when 503 - ServiceUnavailableError - when 500..599 - ServerError - else - ApiError - end - end end end -end +end \ No newline at end of file diff --git a/lib/square/errors/client_error.rb b/lib/square/errors/client_error.rb index 0a356795..00385624 100644 --- a/lib/square/errors/client_error.rb +++ b/lib/square/errors/client_error.rb @@ -1,6 +1,6 @@ module Square module Errors - class ClientError < ApiError + class ClientError < ResponseError end class UnauthorizedError < ClientError diff --git a/lib/square/errors/redirect_error.rb b/lib/square/errors/redirect_error.rb index 0641fdf2..5ccd206a 100644 --- a/lib/square/errors/redirect_error.rb +++ b/lib/square/errors/redirect_error.rb @@ -1,6 +1,6 @@ module Square module Errors - class RedirectError < ApiError + class RedirectError < ResponseError end end end diff --git a/lib/square/errors/response_error.rb b/lib/square/errors/response_error.rb new file mode 100644 index 00000000..f8f1fc03 --- /dev/null +++ b/lib/square/errors/response_error.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Square + module Errors + class ResponseError < ApiError + attr_reader :code + + def initialize(msg, code:) + @code = code + super(msg) + end + + # Returns the most appropriate error class for the given code. + # + # @return [Class] + def self.subclass_for_code(code) + case code + when 300..399 + RedirectError + when 401 + UnauthorizedError + when 403 + ForbiddenError + when 404 + NotFoundError + when 400..499 + ClientError + when 503 + ServiceUnavailableError + when 500..599 + ServerError + else + ResponseError + end + end + end + end +end diff --git a/lib/square/errors/server_error.rb b/lib/square/errors/server_error.rb index 6786cabf..0b17ed86 100644 --- a/lib/square/errors/server_error.rb +++ b/lib/square/errors/server_error.rb @@ -1,6 +1,6 @@ module Square module Errors - class ServerError < ApiError + class ServerError < ResponseError end class ServiceUnavailableError < ApiError diff --git a/lib/square/errors/timeout_error.rb b/lib/square/errors/timeout_error.rb index b59605c2..381fa971 100644 --- a/lib/square/errors/timeout_error.rb +++ b/lib/square/errors/timeout_error.rb @@ -1,6 +1,6 @@ module Square module Errors - class TimeoutError < StandardError + class TimeoutError < ApiError end end end From 37ca77b13f560b925997aa32e6c8e8b48251fbb2 Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Mon, 15 Sep 2025 14:28:59 -0400 Subject: [PATCH 05/22] Minor tweaks. --- lib/square/cards/client.rb | 2 +- lib/square/errors/api_error.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/square/cards/client.rb b/lib/square/cards/client.rb index c1f20a29..6127a5c6 100644 --- a/lib/square/cards/client.rb +++ b/lib/square/cards/client.rb @@ -33,7 +33,7 @@ def list(request_options: {}, **params) if code.between?(200, 299) Square::Types::ListCardsResponse.load(_response.body) else - subclass = Square::Errors::ApiError.subclass_for_code(code) + subclass = Square::Errors::ResponseError.subclass_for_code(code) raise subclass.new(_response.body, code: code) end end diff --git a/lib/square/errors/api_error.rb b/lib/square/errors/api_error.rb index bcd3fdd3..2df704c1 100644 --- a/lib/square/errors/api_error.rb +++ b/lib/square/errors/api_error.rb @@ -3,4 +3,4 @@ module Errors class ApiError < StandardError end end -end \ No newline at end of file +end From 4f38006562761da8a6cf5f9b2b5155931340aa71 Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Wed, 17 Sep 2025 12:21:58 -0400 Subject: [PATCH 06/22] Initial implementation of ItemIterator and PageIterator. --- lib/square/cards/client.rb | 42 +++++++++---------- .../internal/iterators/item_iterator.rb | 24 +++++++++++ .../internal/iterators/page_iterator.rb | 22 ++++++++++ 3 files changed, 67 insertions(+), 21 deletions(-) create mode 100644 lib/square/internal/iterators/item_iterator.rb create mode 100644 lib/square/internal/iterators/page_iterator.rb diff --git a/lib/square/cards/client.rb b/lib/square/cards/client.rb index 6127a5c6..f58ad3ee 100644 --- a/lib/square/cards/client.rb +++ b/lib/square/cards/client.rb @@ -8,33 +8,33 @@ def initialize(client:) @client = client end - # Retrieves a list of cards owned by the account making the request. - # A max of 25 cards will be returned. + # Retrieves an ItemIterator of cards owned by the account making the request. # - # @return [Square::Types::ListCardsResponse] + # @return Square::Internal::ItemIterator def list(request_options: {}, **params) _query_param_names = %w[cursor customer_id include_disabled reference_id sort_order] _query = params.slice(*_query_param_names) - params.except(*_query_param_names) - _request = Square::Internal::JSON::Request.new( - base_url: request_options[:base_url] || Square::Environment::SANDBOX, - method: "GET", - path: "v2/cards", - query: _query - ) + Square::Internal::ItemIterator.new(item_field: :cards, cursor: params[:cursor]) do |cursor| + _request = Square::Internal::JSON::Request.new( + base_url: request_options[:base_url] || Square::Environment::SANDBOX, + method: "GET", + path: "v2/cards", + query: _query.merge(cursor: cursor) + ) - begin - _response = @client.send(_request) - rescue Net::HTTPRequestTimeout - raise Square::Errors::TimeoutError - end - code = _response.code.to_i - if code.between?(200, 299) - Square::Types::ListCardsResponse.load(_response.body) - else - subclass = Square::Errors::ResponseError.subclass_for_code(code) - raise subclass.new(_response.body, code: code) + begin + _response = @client.send(_request) + rescue Net::HTTPRequestTimeout + raise Square::Errors::TimeoutError + end + code = _response.code.to_i + if code.between?(200, 299) + Square::Types::ListCardsResponse.load(_response.body) + else + subclass = Square::Errors::ResponseError.subclass_for_code(code) + raise subclass.new(_response.body, code: code) + end end end diff --git a/lib/square/internal/iterators/item_iterator.rb b/lib/square/internal/iterators/item_iterator.rb new file mode 100644 index 00000000..f4029207 --- /dev/null +++ b/lib/square/internal/iterators/item_iterator.rb @@ -0,0 +1,24 @@ +require 'enumerable' + +module Square + module Internal + class ItemIterator + include Enumerable + + def initialize(initial_cursor:, item_field:, &block) + @item_field = item_field + @page_iterator = PageIterator.new(initial_cursor:, &block) + end + + def each(&block) + @page_iterator.each do |page| + page.send(@item_field).each(&block) + end + end + + def pages + @page_iterator + end + end + end +end diff --git a/lib/square/internal/iterators/page_iterator.rb b/lib/square/internal/iterators/page_iterator.rb new file mode 100644 index 00000000..b5fbd8ca --- /dev/null +++ b/lib/square/internal/iterators/page_iterator.rb @@ -0,0 +1,22 @@ +require 'enumerable' + +module Square + module Internal + class PageIterator + include Enumerable + + def initialize(initial_cursor:, &block) + @cursor = initial_cursor + @get_next_page = block + end + + def each(&block) + while @cursor do + next_page = @get_next_page.call(@cursor) + @cursor = next_page.cursor + block.call(next_page) + end + end + end + end +end From b06c0d65db3e60ec476d2a9c1fdcf6d474d5080a Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Wed, 17 Sep 2025 13:55:25 -0400 Subject: [PATCH 07/22] Get basic iterator tests passing. --- lib/square.rb | 2 + lib/square/cards/client.rb | 2 +- .../internal/iterators/item_iterator.rb | 2 - .../internal/iterators/page_iterator.rb | 2 - .../internal/iterators/test_item_iterator.rb | 58 +++++++++++++++++++ 5 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 test/square/internal/iterators/test_item_iterator.rb diff --git a/lib/square.rb b/lib/square.rb index 736d33a5..474bb7b6 100644 --- a/lib/square.rb +++ b/lib/square.rb @@ -25,6 +25,8 @@ require_relative "square/internal/types/enum" require_relative "square/internal/types/hash" require_relative "square/internal/types/unknown" +require_relative "square/internal/iterators/item_iterator" +require_relative "square/internal/iterators/page_iterator" # API Types require_relative "square/file_param" diff --git a/lib/square/cards/client.rb b/lib/square/cards/client.rb index f58ad3ee..2e480547 100644 --- a/lib/square/cards/client.rb +++ b/lib/square/cards/client.rb @@ -15,7 +15,7 @@ def list(request_options: {}, **params) _query_param_names = %w[cursor customer_id include_disabled reference_id sort_order] _query = params.slice(*_query_param_names) - Square::Internal::ItemIterator.new(item_field: :cards, cursor: params[:cursor]) do |cursor| + Square::Internal::ItemIterator.new(item_field: :cards, initial_cursor: params[:cursor]) do |cursor| _request = Square::Internal::JSON::Request.new( base_url: request_options[:base_url] || Square::Environment::SANDBOX, method: "GET", diff --git a/lib/square/internal/iterators/item_iterator.rb b/lib/square/internal/iterators/item_iterator.rb index f4029207..03555f15 100644 --- a/lib/square/internal/iterators/item_iterator.rb +++ b/lib/square/internal/iterators/item_iterator.rb @@ -1,5 +1,3 @@ -require 'enumerable' - module Square module Internal class ItemIterator diff --git a/lib/square/internal/iterators/page_iterator.rb b/lib/square/internal/iterators/page_iterator.rb index b5fbd8ca..c085dae1 100644 --- a/lib/square/internal/iterators/page_iterator.rb +++ b/lib/square/internal/iterators/page_iterator.rb @@ -1,5 +1,3 @@ -require 'enumerable' - module Square module Internal class PageIterator diff --git a/test/square/internal/iterators/test_item_iterator.rb b/test/square/internal/iterators/test_item_iterator.rb new file mode 100644 index 00000000..5ff815d1 --- /dev/null +++ b/test/square/internal/iterators/test_item_iterator.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "stringio" +require "json" +require "test_helper" +require "ostruct" + +NUMBERS = (1..65).to_a + +def get_iterator(initial_cursor:) + Square::Internal::ItemIterator.new(initial_cursor:, item_field: :cards) do |cursor| + next_cursor = cursor + 10 + OpenStruct.new( + cards: NUMBERS[cursor...next_cursor], + cursor: next_cursor < NUMBERS.length ? next_cursor : nil + ) + end +end + +class ItemIteratorTest < Minitest::Test + def test_basic_iterator + iterator = get_iterator(initial_cursor: 0) + assert_equal NUMBERS, iterator.to_a + + iterator = get_iterator(initial_cursor: 10) + assert_equal (11..65).to_a, iterator.to_a + end + + def test_pages_iterator + iterator = get_iterator(initial_cursor: 0).pages + assert_equal( + [ + (1..10).to_a, + (11..20).to_a, + (21..30).to_a, + (31..40).to_a, + (41..50).to_a, + (51..60).to_a, + (61..65).to_a, + ], + iterator.to_a.map{|p| p.cards} + ) + + iterator = get_iterator(initial_cursor: 10).pages + assert_equal( + [ + (11..20).to_a, + (21..30).to_a, + (31..40).to_a, + (41..50).to_a, + (51..60).to_a, + (61..65).to_a, + ], + iterator.to_a.map{|p| p.cards} + ) + end +end From 6b0104058dcbf199175921755197f9b7289ddfb1 Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Wed, 17 Sep 2025 14:12:19 -0400 Subject: [PATCH 08/22] Test that iterators only call the API as many times as is needed. --- .../internal/iterators/test_item_iterator.rb | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/test/square/internal/iterators/test_item_iterator.rb b/test/square/internal/iterators/test_item_iterator.rb index 5ff815d1..d2a7cf62 100644 --- a/test/square/internal/iterators/test_item_iterator.rb +++ b/test/square/internal/iterators/test_item_iterator.rb @@ -8,18 +8,8 @@ NUMBERS = (1..65).to_a -def get_iterator(initial_cursor:) - Square::Internal::ItemIterator.new(initial_cursor:, item_field: :cards) do |cursor| - next_cursor = cursor + 10 - OpenStruct.new( - cards: NUMBERS[cursor...next_cursor], - cursor: next_cursor < NUMBERS.length ? next_cursor : nil - ) - end -end - class ItemIteratorTest < Minitest::Test - def test_basic_iterator + def test_item_iterator_can_iterate_to_exhaustion iterator = get_iterator(initial_cursor: 0) assert_equal NUMBERS, iterator.to_a @@ -27,6 +17,18 @@ def test_basic_iterator assert_equal (11..65).to_a, iterator.to_a end + def test_items_iterator_iterates_lazily + iterator = get_iterator(initial_cursor: 0) + assert_equal 0, @times_called + assert_equal 1, iterator.first + assert_equal 1, @times_called + + iterator = get_iterator(initial_cursor: 0) + assert_equal 0, @times_called + assert_equal (1..15).to_a, iterator.first(15) + assert_equal 2, @times_called + end + def test_pages_iterator iterator = get_iterator(initial_cursor: 0).pages assert_equal( @@ -55,4 +57,17 @@ def test_pages_iterator iterator.to_a.map{|p| p.cards} ) end + + def get_iterator(initial_cursor:) + @times_called = 0 + + Square::Internal::ItemIterator.new(initial_cursor:, item_field: :cards) do |cursor| + @times_called += 1 + next_cursor = cursor + 10 + OpenStruct.new( + cards: NUMBERS[cursor...next_cursor], + cursor: next_cursor < NUMBERS.length ? next_cursor : nil + ) + end + end end From ed9fd57e15f6fadb178c0e4456f8a108b470b5dd Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Wed, 17 Sep 2025 14:20:58 -0400 Subject: [PATCH 09/22] Cleanup and expand tests. --- .../internal/iterators/test_item_iterator.rb | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/test/square/internal/iterators/test_item_iterator.rb b/test/square/internal/iterators/test_item_iterator.rb index d2a7cf62..a1c7bf56 100644 --- a/test/square/internal/iterators/test_item_iterator.rb +++ b/test/square/internal/iterators/test_item_iterator.rb @@ -9,28 +9,51 @@ NUMBERS = (1..65).to_a class ItemIteratorTest < Minitest::Test + def make_iterator(initial_cursor:) + @times_called = 0 + + Square::Internal::ItemIterator.new(initial_cursor:, item_field: :cards) do |cursor| + @times_called += 1 + next_cursor = cursor + 10 + OpenStruct.new( + cards: NUMBERS[cursor...next_cursor], + cursor: next_cursor < NUMBERS.length ? next_cursor : nil + ) + end + end + def test_item_iterator_can_iterate_to_exhaustion - iterator = get_iterator(initial_cursor: 0) + iterator = make_iterator(initial_cursor: 0) assert_equal NUMBERS, iterator.to_a + assert_equal 7, @times_called - iterator = get_iterator(initial_cursor: 10) + iterator = make_iterator(initial_cursor: 10) assert_equal (11..65).to_a, iterator.to_a end def test_items_iterator_iterates_lazily - iterator = get_iterator(initial_cursor: 0) + iterator = make_iterator(initial_cursor: 0) assert_equal 0, @times_called assert_equal 1, iterator.first assert_equal 1, @times_called - iterator = get_iterator(initial_cursor: 0) + iterator = make_iterator(initial_cursor: 0) assert_equal 0, @times_called assert_equal (1..15).to_a, iterator.first(15) assert_equal 2, @times_called + + iterator = make_iterator(initial_cursor: 0) + assert_equal 0, @times_called + iterator.each do |card| + if card >= 15 + break; + end + end + assert_equal 2, @times_called end def test_pages_iterator - iterator = get_iterator(initial_cursor: 0).pages + iterator = make_iterator(initial_cursor: 0).pages assert_equal( [ (1..10).to_a, @@ -44,7 +67,7 @@ def test_pages_iterator iterator.to_a.map{|p| p.cards} ) - iterator = get_iterator(initial_cursor: 10).pages + iterator = make_iterator(initial_cursor: 10).pages assert_equal( [ (11..20).to_a, @@ -58,16 +81,15 @@ def test_pages_iterator ) end - def get_iterator(initial_cursor:) - @times_called = 0 + def test_pages_iterator_iterates_lazily + iterator = make_iterator(initial_cursor: 0).pages + assert_equal 0, @times_called + iterator.first + assert_equal 1, @times_called - Square::Internal::ItemIterator.new(initial_cursor:, item_field: :cards) do |cursor| - @times_called += 1 - next_cursor = cursor + 10 - OpenStruct.new( - cards: NUMBERS[cursor...next_cursor], - cursor: next_cursor < NUMBERS.length ? next_cursor : nil - ) - end + iterator = make_iterator(initial_cursor: 0).pages + assert_equal 0, @times_called + assert_equal 2, iterator.first(2).length + assert_equal 2, @times_called end end From 352cd2fc2bf0eabb96bd0bb712d8deb5d3a42cfe Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Wed, 17 Sep 2025 14:47:34 -0400 Subject: [PATCH 10/22] Add has_next_page? method to PageIterator. --- lib/square/internal/iterators/page_iterator.rb | 4 ++++ test/square/internal/iterators/test_item_iterator.rb | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/lib/square/internal/iterators/page_iterator.rb b/lib/square/internal/iterators/page_iterator.rb index c085dae1..281dd3d7 100644 --- a/lib/square/internal/iterators/page_iterator.rb +++ b/lib/square/internal/iterators/page_iterator.rb @@ -15,6 +15,10 @@ def each(&block) block.call(next_page) end end + + def has_next_page? + !@cursor.nil? + end end end end diff --git a/test/square/internal/iterators/test_item_iterator.rb b/test/square/internal/iterators/test_item_iterator.rb index a1c7bf56..363ab06e 100644 --- a/test/square/internal/iterators/test_item_iterator.rb +++ b/test/square/internal/iterators/test_item_iterator.rb @@ -92,4 +92,13 @@ def test_pages_iterator_iterates_lazily assert_equal 2, iterator.first(2).length assert_equal 2, @times_called end + + def test_pages_iterator_knows_whether_another_page_is_upcoming + iterator = make_iterator(initial_cursor: 0).pages + + iterator.each_with_index do |page, index| + assert_equal index + 1, @times_called + assert_equal index < 6, iterator.has_next_page? + end + end end From 9053ad1d8c6a1e60bf81ca59f9a825d9d8d3bbc3 Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Wed, 17 Sep 2025 14:54:07 -0400 Subject: [PATCH 11/22] More tests. --- .../internal/iterators/test_item_iterator.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/square/internal/iterators/test_item_iterator.rb b/test/square/internal/iterators/test_item_iterator.rb index 363ab06e..fad0bf62 100644 --- a/test/square/internal/iterators/test_item_iterator.rb +++ b/test/square/internal/iterators/test_item_iterator.rb @@ -52,6 +52,14 @@ def test_items_iterator_iterates_lazily assert_equal 2, @times_called end + def test_items_iterator_implements_enumerable + iterator = make_iterator(initial_cursor: 0) + assert_equal 0, @times_called + doubled = iterator.map{|card| card * 2} + assert_equal 7, @times_called + assert_equal NUMBERS.length, doubled.length + end + def test_pages_iterator iterator = make_iterator(initial_cursor: 0).pages assert_equal( @@ -101,4 +109,12 @@ def test_pages_iterator_knows_whether_another_page_is_upcoming assert_equal index < 6, iterator.has_next_page? end end + + def test_pages_iterator_implements_enumerable + iterator = make_iterator(initial_cursor: 0).pages + assert_equal 0, @times_called + lengths = iterator.map{|page| page.cards.length} + assert_equal 7, @times_called + assert_equal [10, 10, 10, 10, 10, 10, 5], lengths + end end From a27ade5aa25a221a46a33cdc1349d1c68e75ff2f Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Wed, 17 Sep 2025 15:10:05 -0400 Subject: [PATCH 12/22] Support manual iteration for pages. --- lib/square/internal/iterators/page_iterator.rb | 11 ++++++++--- .../internal/iterators/test_item_iterator.rb | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/square/internal/iterators/page_iterator.rb b/lib/square/internal/iterators/page_iterator.rb index 281dd3d7..f2d131d9 100644 --- a/lib/square/internal/iterators/page_iterator.rb +++ b/lib/square/internal/iterators/page_iterator.rb @@ -10,15 +10,20 @@ def initialize(initial_cursor:, &block) def each(&block) while @cursor do - next_page = @get_next_page.call(@cursor) - @cursor = next_page.cursor - block.call(next_page) + block.call(get_next_page) end end def has_next_page? !@cursor.nil? end + + def get_next_page + return if @cursor.nil? + next_page = @get_next_page.call(@cursor) + @cursor = next_page.cursor + next_page + end end end end diff --git a/test/square/internal/iterators/test_item_iterator.rb b/test/square/internal/iterators/test_item_iterator.rb index fad0bf62..7689bce4 100644 --- a/test/square/internal/iterators/test_item_iterator.rb +++ b/test/square/internal/iterators/test_item_iterator.rb @@ -110,6 +110,22 @@ def test_pages_iterator_knows_whether_another_page_is_upcoming end end + def test_pages_iterator_can_be_advanced_manually + iterator = make_iterator(initial_cursor: 0).pages + assert_equal 0, @times_called + + lengths = [] + expected_times_called = 0 + while page = iterator.get_next_page do + expected_times_called += 1 + assert_equal expected_times_called, @times_called + lengths.push(page.cards.length) + end + + assert_equal 7, @times_called + assert_equal [10, 10, 10, 10, 10, 10, 5], lengths + end + def test_pages_iterator_implements_enumerable iterator = make_iterator(initial_cursor: 0).pages assert_equal 0, @times_called From 49722a2279b0d23fa6bc6f485957185f66d0af56 Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Wed, 17 Sep 2025 15:19:05 -0400 Subject: [PATCH 13/22] Support manual iteration for ItemIterator as well. --- .../internal/iterators/item_iterator.rb | 23 +++++++++++++++---- .../internal/iterators/page_iterator.rb | 4 ++-- .../internal/iterators/test_item_iterator.rb | 18 ++++++++++++++- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/lib/square/internal/iterators/item_iterator.rb b/lib/square/internal/iterators/item_iterator.rb index 03555f15..e630607a 100644 --- a/lib/square/internal/iterators/item_iterator.rb +++ b/lib/square/internal/iterators/item_iterator.rb @@ -6,16 +6,31 @@ class ItemIterator def initialize(initial_cursor:, item_field:, &block) @item_field = item_field @page_iterator = PageIterator.new(initial_cursor:, &block) + @page = nil + end + + def pages + @page_iterator end def each(&block) - @page_iterator.each do |page| - page.send(@item_field).each(&block) + while item = get_next do + block.call(item) end end - def pages - @page_iterator + def get_next + item = try_get_next + return item if item + @page = @page_iterator.get_next + try_get_next + end + + private + + def try_get_next + return if !@page + @page.send(@item_field).shift end end end diff --git a/lib/square/internal/iterators/page_iterator.rb b/lib/square/internal/iterators/page_iterator.rb index f2d131d9..286ffb85 100644 --- a/lib/square/internal/iterators/page_iterator.rb +++ b/lib/square/internal/iterators/page_iterator.rb @@ -10,7 +10,7 @@ def initialize(initial_cursor:, &block) def each(&block) while @cursor do - block.call(get_next_page) + block.call(get_next) end end @@ -18,7 +18,7 @@ def has_next_page? !@cursor.nil? end - def get_next_page + def get_next return if @cursor.nil? next_page = @get_next_page.call(@cursor) @cursor = next_page.cursor diff --git a/test/square/internal/iterators/test_item_iterator.rb b/test/square/internal/iterators/test_item_iterator.rb index 7689bce4..57487195 100644 --- a/test/square/internal/iterators/test_item_iterator.rb +++ b/test/square/internal/iterators/test_item_iterator.rb @@ -60,6 +60,22 @@ def test_items_iterator_implements_enumerable assert_equal NUMBERS.length, doubled.length end + def test_items_iterator_can_be_advanced_manually + iterator = make_iterator(initial_cursor: 0) + assert_equal 0, @times_called + + items = [] + expected_times_called = 0 + while item = iterator.get_next do + expected_times_called += 1 if (item % 10) == 1 + assert_equal expected_times_called, @times_called + items.push(item) + end + + assert_equal 7, @times_called + assert_equal NUMBERS, items + end + def test_pages_iterator iterator = make_iterator(initial_cursor: 0).pages assert_equal( @@ -116,7 +132,7 @@ def test_pages_iterator_can_be_advanced_manually lengths = [] expected_times_called = 0 - while page = iterator.get_next_page do + while page = iterator.get_next do expected_times_called += 1 assert_equal expected_times_called, @times_called lengths.push(page.cards.length) From f348ee162d291604e705aa600fa3fd61df4585f5 Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Wed, 17 Sep 2025 15:35:59 -0400 Subject: [PATCH 14/22] Implement has_next? method for ItemIterator as well. --- .../internal/iterators/item_iterator.rb | 26 ++++++++++++++++--- .../internal/iterators/page_iterator.rb | 2 +- .../internal/iterators/test_item_iterator.rb | 3 ++- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/square/internal/iterators/item_iterator.rb b/lib/square/internal/iterators/item_iterator.rb index e630607a..1cf57afa 100644 --- a/lib/square/internal/iterators/item_iterator.rb +++ b/lib/square/internal/iterators/item_iterator.rb @@ -19,19 +19,37 @@ def each(&block) end end + def has_next? + load_next_page if @page.nil? + return false if @page.nil? + + return true if any_items_in_cached_page + load_next_page + any_items_in_cached_page + end + def get_next - item = try_get_next + item = next_item_from_cached_page return item if item - @page = @page_iterator.get_next - try_get_next + load_next_page + next_item_from_cached_page end private - def try_get_next + def next_item_from_cached_page return if !@page @page.send(@item_field).shift end + + def any_items_in_cached_page + return false if !@page + !@page.send(@item_field).empty? + end + + def load_next_page + @page = @page_iterator.get_next + end end end end diff --git a/lib/square/internal/iterators/page_iterator.rb b/lib/square/internal/iterators/page_iterator.rb index 286ffb85..99fc6cac 100644 --- a/lib/square/internal/iterators/page_iterator.rb +++ b/lib/square/internal/iterators/page_iterator.rb @@ -14,7 +14,7 @@ def each(&block) end end - def has_next_page? + def has_next? !@cursor.nil? end diff --git a/test/square/internal/iterators/test_item_iterator.rb b/test/square/internal/iterators/test_item_iterator.rb index 57487195..09d138b3 100644 --- a/test/square/internal/iterators/test_item_iterator.rb +++ b/test/square/internal/iterators/test_item_iterator.rb @@ -69,6 +69,7 @@ def test_items_iterator_can_be_advanced_manually while item = iterator.get_next do expected_times_called += 1 if (item % 10) == 1 assert_equal expected_times_called, @times_called + assert_equal item != NUMBERS.last, iterator.has_next?, "#{item} #{iterator}" items.push(item) end @@ -122,7 +123,7 @@ def test_pages_iterator_knows_whether_another_page_is_upcoming iterator.each_with_index do |page, index| assert_equal index + 1, @times_called - assert_equal index < 6, iterator.has_next_page? + assert_equal index < 6, iterator.has_next? end end From a220cade1780ff4665c390580dfe1885a7fa36ba Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Wed, 17 Sep 2025 16:27:36 -0400 Subject: [PATCH 15/22] Add docstrings to the ItemIterator and PageIterator classes. --- lib/square/internal/iterators/item_iterator.rb | 18 ++++++++++++++++++ lib/square/internal/iterators/page_iterator.rb | 14 ++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/lib/square/internal/iterators/item_iterator.rb b/lib/square/internal/iterators/item_iterator.rb index 1cf57afa..d54b31a8 100644 --- a/lib/square/internal/iterators/item_iterator.rb +++ b/lib/square/internal/iterators/item_iterator.rb @@ -3,22 +3,37 @@ module Internal class ItemIterator include Enumerable + # Instantiates a ItemIterator, an Enumerable class which wraps calls to a paginated API and yields the individual items from the API. + # + # @param initial_cursor [String] The initial cursor to use when iterating. + # @return [Square::Internal::ItemIterator] def initialize(initial_cursor:, item_field:, &block) @item_field = item_field @page_iterator = PageIterator.new(initial_cursor:, &block) @page = nil end + # Returns the PageIterator mediating access to the underlying API. + # + # @param initial_cursor [String] The initial cursor to use when iterating. + # @return [Square::Internal::PageIterator] def pages @page_iterator end + # Iterates over each item returned by the API. + # + # @param block [Proc] The block which is passed every page as it is received. + # @return [nil] def each(&block) while item = get_next do block.call(item) end end + # Whether another item will be available from the API. + # + # @return [Boolean] def has_next? load_next_page if @page.nil? return false if @page.nil? @@ -28,6 +43,9 @@ def has_next? any_items_in_cached_page end + # Retrieves the next item from the API. + # + # @return [Boolean] def get_next item = next_item_from_cached_page return item if item diff --git a/lib/square/internal/iterators/page_iterator.rb b/lib/square/internal/iterators/page_iterator.rb index 99fc6cac..bdccf7e3 100644 --- a/lib/square/internal/iterators/page_iterator.rb +++ b/lib/square/internal/iterators/page_iterator.rb @@ -3,21 +3,35 @@ module Internal class PageIterator include Enumerable + # Instantiates a PageIterator, an Enumerable class which wraps calls to a paginated API and yields pages of items. + # + # @param initial_cursor [String] The initial cursor to use when iterating. + # @return [Square::Internal::PageIterator] def initialize(initial_cursor:, &block) @cursor = initial_cursor @get_next_page = block end + # Iterates over each page returned by the API. + # + # @param block [Proc] The block which is passed every page as it is received. + # @return [nil] def each(&block) while @cursor do block.call(get_next) end end + # Whether another page will be available from the API. + # + # @return [Boolean] def has_next? !@cursor.nil? end + # Retrieves the next page from the API. + # + # @return [Boolean] def get_next return if @cursor.nil? next_page = @get_next_page.call(@cursor) From b5805a5e9e4dc594e91d40996b743ec52d9e70df Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Wed, 17 Sep 2025 16:36:28 -0400 Subject: [PATCH 16/22] Ensure that PageIterator handles fetching the first page, where there isn't a cursor yet. --- lib/square/internal/iterators/page_iterator.rb | 10 ++++++---- .../square/internal/iterators/test_item_iterator.rb | 13 +++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/square/internal/iterators/page_iterator.rb b/lib/square/internal/iterators/page_iterator.rb index bdccf7e3..1e6f60d4 100644 --- a/lib/square/internal/iterators/page_iterator.rb +++ b/lib/square/internal/iterators/page_iterator.rb @@ -8,6 +8,7 @@ class PageIterator # @param initial_cursor [String] The initial cursor to use when iterating. # @return [Square::Internal::PageIterator] def initialize(initial_cursor:, &block) + @need_initial_load = initial_cursor.nil? @cursor = initial_cursor @get_next_page = block end @@ -17,8 +18,8 @@ def initialize(initial_cursor:, &block) # @param block [Proc] The block which is passed every page as it is received. # @return [nil] def each(&block) - while @cursor do - block.call(get_next) + while page = get_next do + block.call(page) end end @@ -26,14 +27,15 @@ def each(&block) # # @return [Boolean] def has_next? - !@cursor.nil? + @need_initial_load || !@cursor.nil? end # Retrieves the next page from the API. # # @return [Boolean] def get_next - return if @cursor.nil? + return if !@need_initial_load && @cursor.nil? + @need_initial_load = false next_page = @get_next_page.call(@cursor) @cursor = next_page.cursor next_page diff --git a/test/square/internal/iterators/test_item_iterator.rb b/test/square/internal/iterators/test_item_iterator.rb index 09d138b3..4ac87652 100644 --- a/test/square/internal/iterators/test_item_iterator.rb +++ b/test/square/internal/iterators/test_item_iterator.rb @@ -14,6 +14,7 @@ def make_iterator(initial_cursor:) Square::Internal::ItemIterator.new(initial_cursor:, item_field: :cards) do |cursor| @times_called += 1 + cursor ||= 0 next_cursor = cursor + 10 OpenStruct.new( cards: NUMBERS[cursor...next_cursor], @@ -31,6 +32,12 @@ def test_item_iterator_can_iterate_to_exhaustion assert_equal (11..65).to_a, iterator.to_a end + def test_item_iterator_can_work_without_an_initial_cursor + iterator = make_iterator(initial_cursor: nil) + assert_equal NUMBERS, iterator.to_a + assert_equal 7, @times_called + end + def test_items_iterator_iterates_lazily iterator = make_iterator(initial_cursor: 0) assert_equal 0, @times_called @@ -106,6 +113,12 @@ def test_pages_iterator ) end + def test_pages_iterator_can_work_without_an_initial_cursor + iterator = make_iterator(initial_cursor: nil).pages + assert_equal 7, iterator.to_a.length + assert_equal 7, @times_called + end + def test_pages_iterator_iterates_lazily iterator = make_iterator(initial_cursor: 0).pages assert_equal 0, @times_called From 9703d5e0d1a361db8aba51e1836bc892df8db07b Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Thu, 18 Sep 2025 12:19:56 -0400 Subject: [PATCH 17/22] Don't hard-code cursor field. --- lib/square/internal/iterators/item_iterator.rb | 7 ++++--- lib/square/internal/iterators/page_iterator.rb | 6 ++++-- test/square/internal/iterators/test_item_iterator.rb | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/square/internal/iterators/item_iterator.rb b/lib/square/internal/iterators/item_iterator.rb index d54b31a8..20c2864b 100644 --- a/lib/square/internal/iterators/item_iterator.rb +++ b/lib/square/internal/iterators/item_iterator.rb @@ -6,16 +6,17 @@ class ItemIterator # Instantiates a ItemIterator, an Enumerable class which wraps calls to a paginated API and yields the individual items from the API. # # @param initial_cursor [String] The initial cursor to use when iterating. + # @param cursor_field [String] The name of the field in API responses to extract the next cursor from. + # @param item_field [String] The name of the field in API responses to extract the items to iterate over. # @return [Square::Internal::ItemIterator] - def initialize(initial_cursor:, item_field:, &block) + def initialize(initial_cursor:, cursor_field:, item_field:, &block) @item_field = item_field - @page_iterator = PageIterator.new(initial_cursor:, &block) + @page_iterator = PageIterator.new(initial_cursor:, cursor_field:, &block) @page = nil end # Returns the PageIterator mediating access to the underlying API. # - # @param initial_cursor [String] The initial cursor to use when iterating. # @return [Square::Internal::PageIterator] def pages @page_iterator diff --git a/lib/square/internal/iterators/page_iterator.rb b/lib/square/internal/iterators/page_iterator.rb index 1e6f60d4..d0bed77e 100644 --- a/lib/square/internal/iterators/page_iterator.rb +++ b/lib/square/internal/iterators/page_iterator.rb @@ -6,10 +6,12 @@ class PageIterator # Instantiates a PageIterator, an Enumerable class which wraps calls to a paginated API and yields pages of items. # # @param initial_cursor [String] The initial cursor to use when iterating. + # @param cursor_field [String] The name of the field in API responses to extract the next cursor from. # @return [Square::Internal::PageIterator] - def initialize(initial_cursor:, &block) + def initialize(initial_cursor:, cursor_field:, &block) @need_initial_load = initial_cursor.nil? @cursor = initial_cursor + @cursor_field = cursor_field @get_next_page = block end @@ -37,7 +39,7 @@ def get_next return if !@need_initial_load && @cursor.nil? @need_initial_load = false next_page = @get_next_page.call(@cursor) - @cursor = next_page.cursor + @cursor = next_page.send(@cursor_field) next_page end end diff --git a/test/square/internal/iterators/test_item_iterator.rb b/test/square/internal/iterators/test_item_iterator.rb index 4ac87652..17bef2e1 100644 --- a/test/square/internal/iterators/test_item_iterator.rb +++ b/test/square/internal/iterators/test_item_iterator.rb @@ -12,13 +12,13 @@ class ItemIteratorTest < Minitest::Test def make_iterator(initial_cursor:) @times_called = 0 - Square::Internal::ItemIterator.new(initial_cursor:, item_field: :cards) do |cursor| + Square::Internal::ItemIterator.new(initial_cursor:, cursor_field: :next_cursor, item_field: :cards) do |cursor| @times_called += 1 cursor ||= 0 next_cursor = cursor + 10 OpenStruct.new( cards: NUMBERS[cursor...next_cursor], - cursor: next_cursor < NUMBERS.length ? next_cursor : nil + next_cursor: next_cursor < NUMBERS.length ? next_cursor : nil ) end end From 586d60e953019b019577d03428e3ee8526ee59df Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Thu, 18 Sep 2025 15:10:04 -0400 Subject: [PATCH 18/22] First pass at offset-based auto-pagination. --- lib/square.rb | 2 + .../iterators/offset_item_iterator.rb | 74 ++++++++ .../iterators/offset_page_iterator.rb | 45 +++++ .../iterators/test_offset_item_iterator.rb | 165 ++++++++++++++++++ 4 files changed, 286 insertions(+) create mode 100644 lib/square/internal/iterators/offset_item_iterator.rb create mode 100644 lib/square/internal/iterators/offset_page_iterator.rb create mode 100644 test/square/internal/iterators/test_offset_item_iterator.rb diff --git a/lib/square.rb b/lib/square.rb index 474bb7b6..23e7d6cc 100644 --- a/lib/square.rb +++ b/lib/square.rb @@ -27,6 +27,8 @@ require_relative "square/internal/types/unknown" require_relative "square/internal/iterators/item_iterator" require_relative "square/internal/iterators/page_iterator" +require_relative "square/internal/iterators/offset_item_iterator" +require_relative "square/internal/iterators/offset_page_iterator" # API Types require_relative "square/file_param" diff --git a/lib/square/internal/iterators/offset_item_iterator.rb b/lib/square/internal/iterators/offset_item_iterator.rb new file mode 100644 index 00000000..cf7e9d65 --- /dev/null +++ b/lib/square/internal/iterators/offset_item_iterator.rb @@ -0,0 +1,74 @@ +module Square + module Internal + class OffsetItemIterator + include Enumerable + + # Instantiates a ItemIterator, an Enumerable class which wraps calls to a paginated API and yields the individual items from the API. + # + # @param initial_cursor [String] The initial cursor to use when iterating. + # @param cursor_field [String] The name of the field in API responses to extract the next cursor from. + # @param item_field [String] The name of the field in API responses to extract the items to iterate over. + # @return [Square::Internal::ItemIterator] + def initialize(initial_page:, page_field:, item_field:, &block) + @item_field = item_field + @page_iterator = OffsetPageIterator.new(initial_page:, page_field:, &block) + @page = nil + end + + # Returns the PageIterator mediating access to the underlying API. + # + # @return [Square::Internal::PageIterator] + def pages + @page_iterator + end + + # Iterates over each item returned by the API. + # + # @param block [Proc] The block which is passed every page as it is received. + # @return [nil] + def each(&block) + while item = get_next do + block.call(item) + end + end + + # Whether another item will be available from the API. + # + # @return [Boolean] + def has_next? + load_next_page if @page.nil? + return false if @page.nil? + + return true if any_items_in_cached_page + load_next_page + any_items_in_cached_page + end + + # Retrieves the next item from the API. + # + # @return [Boolean] + def get_next + item = next_item_from_cached_page + return item if item + load_next_page + next_item_from_cached_page + end + + private + + def next_item_from_cached_page + return if !@page + @page.send(@item_field).shift + end + + def any_items_in_cached_page + return false if !@page + !@page.send(@item_field).empty? + end + + def load_next_page + @page = @page_iterator.get_next + end + end + end +end diff --git a/lib/square/internal/iterators/offset_page_iterator.rb b/lib/square/internal/iterators/offset_page_iterator.rb new file mode 100644 index 00000000..296259ac --- /dev/null +++ b/lib/square/internal/iterators/offset_page_iterator.rb @@ -0,0 +1,45 @@ +module Square + module Internal + class OffsetPageIterator + include Enumerable + + # Instantiates a PageIterator, an Enumerable class which wraps calls to a paginated API and yields pages of items. + # + # @param initial_cursor [String] The initial cursor to use when iterating. + # @param cursor_field [String] The name of the field in API responses to extract the next cursor from. + # @return [Square::Internal::PageIterator] + def initialize(initial_page:, page_field:, &block) + @page_number = initial_page || 1 + @page_field = page_field + @get_next_page = block + end + + # Iterates over each page returned by the API. + # + # @param block [Proc] The block which is passed every page as it is received. + # @return [nil] + def each(&block) + while page = get_next do + block.call(page) + end + end + + # Whether another page will be available from the API. + # + # @return [Boolean] + def has_next? + !!@page_number + end + + # Retrieves the next page from the API. + # + # @return [Boolean] + def get_next + return nil if @page_number.nil? + next_page = @get_next_page.call(@page_number) + @page_number = next_page.send(@page_field) + next_page + end + end + end +end diff --git a/test/square/internal/iterators/test_offset_item_iterator.rb b/test/square/internal/iterators/test_offset_item_iterator.rb new file mode 100644 index 00000000..62731831 --- /dev/null +++ b/test/square/internal/iterators/test_offset_item_iterator.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "stringio" +require "json" +require "test_helper" +require "ostruct" + +NUMBERS = (1..65).to_a + +class ItemIteratorTest < Minitest::Test + def make_iterator(initial_page:) + @times_called = 0 + + Square::Internal::OffsetItemIterator.new(initial_page:, page_field: :next_page, item_field: :cards) do |page| + @times_called += 1 + next_page = page + 1 + OpenStruct.new( + cards: NUMBERS[((page - 1) * 10)...((next_page - 1) * 10)], + next_page: NUMBERS.length > page * 10 ? next_page : nil + ) + end + end + + def test_item_iterator_can_iterate_to_exhaustion + iterator = make_iterator(initial_page: 1) + assert_equal NUMBERS, iterator.to_a + assert_equal 7, @times_called + + iterator = make_iterator(initial_page: 2) + assert_equal (11..65).to_a, iterator.to_a + end + + def test_item_iterator_can_work_without_an_initial_page + iterator = make_iterator(initial_page: nil) + assert_equal NUMBERS, iterator.to_a + assert_equal 7, @times_called + end + + def test_items_iterator_iterates_lazily + iterator = make_iterator(initial_page: 1) + assert_equal 0, @times_called + assert_equal 1, iterator.first + assert_equal 1, @times_called + + iterator = make_iterator(initial_page: 1) + assert_equal 0, @times_called + assert_equal (1..15).to_a, iterator.first(15) + assert_equal 2, @times_called + + iterator = make_iterator(initial_page: 1) + assert_equal 0, @times_called + iterator.each do |card| + if card >= 15 + break; + end + end + assert_equal 2, @times_called + end + + def test_items_iterator_implements_enumerable + iterator = make_iterator(initial_page: 1) + assert_equal 0, @times_called + doubled = iterator.map{|card| card * 2} + assert_equal 7, @times_called + assert_equal NUMBERS.length, doubled.length + end + + def test_items_iterator_can_be_advanced_manually + iterator = make_iterator(initial_page: 1) + assert_equal 0, @times_called + + items = [] + expected_times_called = 0 + while item = iterator.get_next do + expected_times_called += 1 if (item % 10) == 1 + assert_equal expected_times_called, @times_called + assert_equal item != NUMBERS.last, iterator.has_next?, "#{item} #{iterator}" + items.push(item) + end + + assert_equal 7, @times_called + assert_equal NUMBERS, items + end + + def test_pages_iterator + iterator = make_iterator(initial_page: 1).pages + assert_equal( + [ + (1..10).to_a, + (11..20).to_a, + (21..30).to_a, + (31..40).to_a, + (41..50).to_a, + (51..60).to_a, + (61..65).to_a, + ], + iterator.to_a.map{|p| p.cards} + ) + + iterator = make_iterator(initial_page: 2).pages + assert_equal( + [ + (11..20).to_a, + (21..30).to_a, + (31..40).to_a, + (41..50).to_a, + (51..60).to_a, + (61..65).to_a, + ], + iterator.to_a.map{|p| p.cards} + ) + end + + def test_pages_iterator_can_work_without_an_initial_page + iterator = make_iterator(initial_page: nil).pages + assert_equal 7, iterator.to_a.length + assert_equal 7, @times_called + end + + def test_pages_iterator_iterates_lazily + iterator = make_iterator(initial_page: 1).pages + assert_equal 0, @times_called + iterator.first + assert_equal 1, @times_called + + iterator = make_iterator(initial_page: 1).pages + assert_equal 0, @times_called + assert_equal 2, iterator.first(2).length + assert_equal 2, @times_called + end + + def test_pages_iterator_knows_whether_another_page_is_upcoming + iterator = make_iterator(initial_page: 1).pages + + iterator.each_with_index do |page, index| + assert_equal index + 1, @times_called + assert_equal index < 6, iterator.has_next? + end + end + + def test_pages_iterator_can_be_advanced_manually + iterator = make_iterator(initial_page: 1).pages + assert_equal 0, @times_called + + lengths = [] + expected_times_called = 0 + while page = iterator.get_next do + expected_times_called += 1 + assert_equal expected_times_called, @times_called + lengths.push(page.cards.length) + end + + assert_equal 7, @times_called + assert_equal [10, 10, 10, 10, 10, 10, 5], lengths + end + + def test_pages_iterator_implements_enumerable + iterator = make_iterator(initial_page: 1).pages + assert_equal 0, @times_called + lengths = iterator.map{|page| page.cards.length} + assert_equal 7, @times_called + assert_equal [10, 10, 10, 10, 10, 10, 5], lengths + end +end From 65d6ffe8ceb83826a8d465e6b3984078afd01270 Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Thu, 18 Sep 2025 15:26:16 -0400 Subject: [PATCH 19/22] Split iterator class into two, one cursor-based and one offset-based. --- lib/square.rb | 4 ++-- ...em_iterator.rb => cursor_item_iterator.rb} | 19 +++++++++---------- ...ge_iterator.rb => cursor_page_iterator.rb} | 8 ++++---- .../iterators/offset_item_iterator.rb | 9 ++++----- .../iterators/offset_page_iterator.rb | 4 ++-- ...erator.rb => test_cursor_item_iterator.rb} | 4 ++-- .../iterators/test_offset_item_iterator.rb | 2 +- 7 files changed, 24 insertions(+), 26 deletions(-) rename lib/square/internal/iterators/{item_iterator.rb => cursor_item_iterator.rb} (65%) rename lib/square/internal/iterators/{page_iterator.rb => cursor_page_iterator.rb} (80%) rename test/square/internal/iterators/{test_item_iterator.rb => test_cursor_item_iterator.rb} (96%) diff --git a/lib/square.rb b/lib/square.rb index 23e7d6cc..39b09d87 100644 --- a/lib/square.rb +++ b/lib/square.rb @@ -25,8 +25,8 @@ require_relative "square/internal/types/enum" require_relative "square/internal/types/hash" require_relative "square/internal/types/unknown" -require_relative "square/internal/iterators/item_iterator" -require_relative "square/internal/iterators/page_iterator" +require_relative "square/internal/iterators/cursor_item_iterator" +require_relative "square/internal/iterators/cursor_page_iterator" require_relative "square/internal/iterators/offset_item_iterator" require_relative "square/internal/iterators/offset_page_iterator" diff --git a/lib/square/internal/iterators/item_iterator.rb b/lib/square/internal/iterators/cursor_item_iterator.rb similarity index 65% rename from lib/square/internal/iterators/item_iterator.rb rename to lib/square/internal/iterators/cursor_item_iterator.rb index 20c2864b..27797f1c 100644 --- a/lib/square/internal/iterators/item_iterator.rb +++ b/lib/square/internal/iterators/cursor_item_iterator.rb @@ -1,30 +1,29 @@ module Square module Internal - class ItemIterator + class CursorItemIterator include Enumerable - # Instantiates a ItemIterator, an Enumerable class which wraps calls to a paginated API and yields the individual items from the API. + # Instantiates a CursorItemIterator, an Enumerable class which wraps calls to a cursor-based paginated API and yields individual items from it. # - # @param initial_cursor [String] The initial cursor to use when iterating. - # @param cursor_field [String] The name of the field in API responses to extract the next cursor from. - # @param item_field [String] The name of the field in API responses to extract the items to iterate over. - # @return [Square::Internal::ItemIterator] + # @param initial_cursor [String] The initial cursor to use when iterating, if any. + # @param cursor_field [Symbol] The field in API responses to extract the next cursor from. + # @param item_field [Symbol] The field in API responses to extract the items to iterate over. def initialize(initial_cursor:, cursor_field:, item_field:, &block) @item_field = item_field - @page_iterator = PageIterator.new(initial_cursor:, cursor_field:, &block) + @page_iterator = CursorPageIterator.new(initial_cursor:, cursor_field:, &block) @page = nil end - # Returns the PageIterator mediating access to the underlying API. + # Returns the CursorPageIterator mediating access to the underlying API. # - # @return [Square::Internal::PageIterator] + # @return [Square::Internal::CursorPageIterator] def pages @page_iterator end # Iterates over each item returned by the API. # - # @param block [Proc] The block which is passed every page as it is received. + # @param block [Proc] The block which each retrieved item is yielded to. # @return [nil] def each(&block) while item = get_next do diff --git a/lib/square/internal/iterators/page_iterator.rb b/lib/square/internal/iterators/cursor_page_iterator.rb similarity index 80% rename from lib/square/internal/iterators/page_iterator.rb rename to lib/square/internal/iterators/cursor_page_iterator.rb index d0bed77e..3eb23753 100644 --- a/lib/square/internal/iterators/page_iterator.rb +++ b/lib/square/internal/iterators/cursor_page_iterator.rb @@ -1,13 +1,13 @@ module Square module Internal - class PageIterator + class CursorPageIterator include Enumerable - # Instantiates a PageIterator, an Enumerable class which wraps calls to a paginated API and yields pages of items. + # Instantiates a CursorPageIterator, an Enumerable class which wraps calls to a paginated API and yields pages of items. # # @param initial_cursor [String] The initial cursor to use when iterating. # @param cursor_field [String] The name of the field in API responses to extract the next cursor from. - # @return [Square::Internal::PageIterator] + # @return [Square::Internal::CursorPageIterator] def initialize(initial_cursor:, cursor_field:, &block) @need_initial_load = initial_cursor.nil? @cursor = initial_cursor @@ -17,7 +17,7 @@ def initialize(initial_cursor:, cursor_field:, &block) # Iterates over each page returned by the API. # - # @param block [Proc] The block which is passed every page as it is received. + # @param block [Proc] The block which each retrieved page is yielded to. # @return [nil] def each(&block) while page = get_next do diff --git a/lib/square/internal/iterators/offset_item_iterator.rb b/lib/square/internal/iterators/offset_item_iterator.rb index cf7e9d65..70813ea9 100644 --- a/lib/square/internal/iterators/offset_item_iterator.rb +++ b/lib/square/internal/iterators/offset_item_iterator.rb @@ -3,28 +3,27 @@ module Internal class OffsetItemIterator include Enumerable - # Instantiates a ItemIterator, an Enumerable class which wraps calls to a paginated API and yields the individual items from the API. + # Instantiates an OffsetItemIterator, an Enumerable class which wraps calls to an offset-based paginated API and yields the individual items from it. # # @param initial_cursor [String] The initial cursor to use when iterating. # @param cursor_field [String] The name of the field in API responses to extract the next cursor from. # @param item_field [String] The name of the field in API responses to extract the items to iterate over. - # @return [Square::Internal::ItemIterator] def initialize(initial_page:, page_field:, item_field:, &block) @item_field = item_field @page_iterator = OffsetPageIterator.new(initial_page:, page_field:, &block) @page = nil end - # Returns the PageIterator mediating access to the underlying API. + # Returns the OffsetPageIterator that is mediating access to the underlying API. # - # @return [Square::Internal::PageIterator] + # @return [Square::Internal::OffsetPageIterator] def pages @page_iterator end # Iterates over each item returned by the API. # - # @param block [Proc] The block which is passed every page as it is received. + # @param block [Proc] The block which each retrieved item is yielded to. # @return [nil] def each(&block) while item = get_next do diff --git a/lib/square/internal/iterators/offset_page_iterator.rb b/lib/square/internal/iterators/offset_page_iterator.rb index 296259ac..7490eb39 100644 --- a/lib/square/internal/iterators/offset_page_iterator.rb +++ b/lib/square/internal/iterators/offset_page_iterator.rb @@ -3,7 +3,7 @@ module Internal class OffsetPageIterator include Enumerable - # Instantiates a PageIterator, an Enumerable class which wraps calls to a paginated API and yields pages of items. + # Instantiates an OffsetPageIterator, an Enumerable class which wraps calls to an offset-based paginated API and yields pages of items from it. # # @param initial_cursor [String] The initial cursor to use when iterating. # @param cursor_field [String] The name of the field in API responses to extract the next cursor from. @@ -16,7 +16,7 @@ def initialize(initial_page:, page_field:, &block) # Iterates over each page returned by the API. # - # @param block [Proc] The block which is passed every page as it is received. + # @param block [Proc] The block which each retrieved page is yielded to. # @return [nil] def each(&block) while page = get_next do diff --git a/test/square/internal/iterators/test_item_iterator.rb b/test/square/internal/iterators/test_cursor_item_iterator.rb similarity index 96% rename from test/square/internal/iterators/test_item_iterator.rb rename to test/square/internal/iterators/test_cursor_item_iterator.rb index 17bef2e1..dde8cdc0 100644 --- a/test/square/internal/iterators/test_item_iterator.rb +++ b/test/square/internal/iterators/test_cursor_item_iterator.rb @@ -8,11 +8,11 @@ NUMBERS = (1..65).to_a -class ItemIteratorTest < Minitest::Test +class CursorItemIteratorTest < Minitest::Test def make_iterator(initial_cursor:) @times_called = 0 - Square::Internal::ItemIterator.new(initial_cursor:, cursor_field: :next_cursor, item_field: :cards) do |cursor| + Square::Internal::CursorItemIterator.new(initial_cursor:, cursor_field: :next_cursor, item_field: :cards) do |cursor| @times_called += 1 cursor ||= 0 next_cursor = cursor + 10 diff --git a/test/square/internal/iterators/test_offset_item_iterator.rb b/test/square/internal/iterators/test_offset_item_iterator.rb index 62731831..747d7478 100644 --- a/test/square/internal/iterators/test_offset_item_iterator.rb +++ b/test/square/internal/iterators/test_offset_item_iterator.rb @@ -8,7 +8,7 @@ NUMBERS = (1..65).to_a -class ItemIteratorTest < Minitest::Test +class OffsetItemIteratorTest < Minitest::Test def make_iterator(initial_page:) @times_called = 0 From 8bbb3076492ebc99b393556e969017f1c6f6fe43 Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Thu, 18 Sep 2025 15:31:42 -0400 Subject: [PATCH 20/22] Merge common logic into an ItemIterator class. --- lib/square.rb | 1 + .../iterators/cursor_item_iterator.rb | 52 +----------------- .../internal/iterators/item_iterator.rb | 55 +++++++++++++++++++ .../iterators/offset_item_iterator.rb | 52 +----------------- 4 files changed, 58 insertions(+), 102 deletions(-) create mode 100644 lib/square/internal/iterators/item_iterator.rb diff --git a/lib/square.rb b/lib/square.rb index 39b09d87..e8d7c5a0 100644 --- a/lib/square.rb +++ b/lib/square.rb @@ -25,6 +25,7 @@ require_relative "square/internal/types/enum" require_relative "square/internal/types/hash" require_relative "square/internal/types/unknown" +require_relative "square/internal/iterators/item_iterator" require_relative "square/internal/iterators/cursor_item_iterator" require_relative "square/internal/iterators/cursor_page_iterator" require_relative "square/internal/iterators/offset_item_iterator" diff --git a/lib/square/internal/iterators/cursor_item_iterator.rb b/lib/square/internal/iterators/cursor_item_iterator.rb index 27797f1c..a8026782 100644 --- a/lib/square/internal/iterators/cursor_item_iterator.rb +++ b/lib/square/internal/iterators/cursor_item_iterator.rb @@ -1,8 +1,6 @@ module Square module Internal - class CursorItemIterator - include Enumerable - + class CursorItemIterator < ItemIterator # Instantiates a CursorItemIterator, an Enumerable class which wraps calls to a cursor-based paginated API and yields individual items from it. # # @param initial_cursor [String] The initial cursor to use when iterating, if any. @@ -20,54 +18,6 @@ def initialize(initial_cursor:, cursor_field:, item_field:, &block) def pages @page_iterator end - - # Iterates over each item returned by the API. - # - # @param block [Proc] The block which each retrieved item is yielded to. - # @return [nil] - def each(&block) - while item = get_next do - block.call(item) - end - end - - # Whether another item will be available from the API. - # - # @return [Boolean] - def has_next? - load_next_page if @page.nil? - return false if @page.nil? - - return true if any_items_in_cached_page - load_next_page - any_items_in_cached_page - end - - # Retrieves the next item from the API. - # - # @return [Boolean] - def get_next - item = next_item_from_cached_page - return item if item - load_next_page - next_item_from_cached_page - end - - private - - def next_item_from_cached_page - return if !@page - @page.send(@item_field).shift - end - - def any_items_in_cached_page - return false if !@page - !@page.send(@item_field).empty? - end - - def load_next_page - @page = @page_iterator.get_next - end end end end diff --git a/lib/square/internal/iterators/item_iterator.rb b/lib/square/internal/iterators/item_iterator.rb new file mode 100644 index 00000000..f5c38884 --- /dev/null +++ b/lib/square/internal/iterators/item_iterator.rb @@ -0,0 +1,55 @@ +module Square + module Internal + class ItemIterator + include Enumerable + + # Iterates over each item returned by the API. + # + # @param block [Proc] The block which each retrieved item is yielded to. + # @return [nil] + def each(&block) + while item = get_next do + block.call(item) + end + end + + # Whether another item will be available from the API. + # + # @return [Boolean] + def has_next? + load_next_page if @page.nil? + return false if @page.nil? + + return true if any_items_in_cached_page + load_next_page + any_items_in_cached_page + end + + # Retrieves the next item from the API. + # + # @return [Boolean] + def get_next + item = next_item_from_cached_page + return item if item + load_next_page + next_item_from_cached_page + end + + private + + def next_item_from_cached_page + return if !@page + @page.send(@item_field).shift + end + + def any_items_in_cached_page + return false if !@page + !@page.send(@item_field).empty? + end + + def load_next_page + @page = @page_iterator.get_next + end + end + end +end diff --git a/lib/square/internal/iterators/offset_item_iterator.rb b/lib/square/internal/iterators/offset_item_iterator.rb index 70813ea9..0a766d52 100644 --- a/lib/square/internal/iterators/offset_item_iterator.rb +++ b/lib/square/internal/iterators/offset_item_iterator.rb @@ -1,8 +1,6 @@ module Square module Internal - class OffsetItemIterator - include Enumerable - + class OffsetItemIterator < ItemIterator # Instantiates an OffsetItemIterator, an Enumerable class which wraps calls to an offset-based paginated API and yields the individual items from it. # # @param initial_cursor [String] The initial cursor to use when iterating. @@ -20,54 +18,6 @@ def initialize(initial_page:, page_field:, item_field:, &block) def pages @page_iterator end - - # Iterates over each item returned by the API. - # - # @param block [Proc] The block which each retrieved item is yielded to. - # @return [nil] - def each(&block) - while item = get_next do - block.call(item) - end - end - - # Whether another item will be available from the API. - # - # @return [Boolean] - def has_next? - load_next_page if @page.nil? - return false if @page.nil? - - return true if any_items_in_cached_page - load_next_page - any_items_in_cached_page - end - - # Retrieves the next item from the API. - # - # @return [Boolean] - def get_next - item = next_item_from_cached_page - return item if item - load_next_page - next_item_from_cached_page - end - - private - - def next_item_from_cached_page - return if !@page - @page.send(@item_field).shift - end - - def any_items_in_cached_page - return false if !@page - !@page.send(@item_field).empty? - end - - def load_next_page - @page = @page_iterator.get_next - end end end end From ba451de1686e2964b5f1cbec40ad6025da582a70 Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Fri, 19 Sep 2025 11:26:15 -0400 Subject: [PATCH 21/22] Add proper step and has_next_page support to offset iterator, and thoroughly test it. --- .../internal/iterators/item_iterator.rb | 7 +- .../iterators/offset_item_iterator.rb | 10 +- .../iterators/offset_page_iterator.rb | 61 ++++- .../iterators/test_cursor_item_iterator.rb | 3 + .../iterators/test_offset_item_iterator.rb | 216 ++++++++---------- 5 files changed, 164 insertions(+), 133 deletions(-) diff --git a/lib/square/internal/iterators/item_iterator.rb b/lib/square/internal/iterators/item_iterator.rb index f5c38884..62baeb03 100644 --- a/lib/square/internal/iterators/item_iterator.rb +++ b/lib/square/internal/iterators/item_iterator.rb @@ -22,6 +22,7 @@ def has_next? return true if any_items_in_cached_page load_next_page + return false if @page.nil? any_items_in_cached_page end @@ -39,12 +40,14 @@ def get_next def next_item_from_cached_page return if !@page - @page.send(@item_field).shift + @page.send(@item_field)&.shift end def any_items_in_cached_page return false if !@page - !@page.send(@item_field).empty? + items = @page.send(@item_field) + return false if items.nil? + !items.empty? end def load_next_page diff --git a/lib/square/internal/iterators/offset_item_iterator.rb b/lib/square/internal/iterators/offset_item_iterator.rb index 0a766d52..e24bc9e9 100644 --- a/lib/square/internal/iterators/offset_item_iterator.rb +++ b/lib/square/internal/iterators/offset_item_iterator.rb @@ -3,12 +3,12 @@ module Internal class OffsetItemIterator < ItemIterator # Instantiates an OffsetItemIterator, an Enumerable class which wraps calls to an offset-based paginated API and yields the individual items from it. # - # @param initial_cursor [String] The initial cursor to use when iterating. - # @param cursor_field [String] The name of the field in API responses to extract the next cursor from. - # @param item_field [String] The name of the field in API responses to extract the items to iterate over. - def initialize(initial_page:, page_field:, item_field:, &block) + # @param initial_page [Integer] The initial cursor to use when iterating. + # @param item_field [Symbol] The name of the field in API responses to extract the items to iterate over. + # @param has_next_field [Symbol] The name of the field in API responses containing a boolean of whether another page exists. + def initialize(initial_page:, item_field:, has_next_field:, step:, &block) @item_field = item_field - @page_iterator = OffsetPageIterator.new(initial_page:, page_field:, &block) + @page_iterator = OffsetPageIterator.new(initial_page:, item_field:, has_next_field:, step:, &block) @page = nil end diff --git a/lib/square/internal/iterators/offset_page_iterator.rb b/lib/square/internal/iterators/offset_page_iterator.rb index 7490eb39..78633748 100644 --- a/lib/square/internal/iterators/offset_page_iterator.rb +++ b/lib/square/internal/iterators/offset_page_iterator.rb @@ -5,13 +5,20 @@ class OffsetPageIterator # Instantiates an OffsetPageIterator, an Enumerable class which wraps calls to an offset-based paginated API and yields pages of items from it. # - # @param initial_cursor [String] The initial cursor to use when iterating. - # @param cursor_field [String] The name of the field in API responses to extract the next cursor from. + # @param initial_page [Integer] The initial page to use when iterating, if any. + # @param item_field [Symbol] The field to pull the list of items to iterate over. + # @param has_next_field [Symbol] The field to pull the boolean of whether a next page exists from, if any. + # @param step [Boolean] If true, treats the page number as a true offset (i.e. increments the page number by the number of items returned from each call rather than just 1) # @return [Square::Internal::PageIterator] - def initialize(initial_page:, page_field:, &block) - @page_number = initial_page || 1 - @page_field = page_field + def initialize(initial_page:, item_field:, has_next_field:, step:, &block) + @page_number = initial_page || (step ? 0 : 1) + @item_field = item_field @get_next_page = block + + @next_page = nil + @has_next_field = has_next_field + @has_next_page = nil + @step = step end # Iterates over each page returned by the API. @@ -28,7 +35,17 @@ def each(&block) # # @return [Boolean] def has_next? - !!@page_number + return @has_next_page unless @has_next_page.nil? + return true if @next_page + + next_page = @get_next_page.call(@page_number) + next_page_items = next_page&.send(@item_field) + if next_page_items.nil? || next_page_items.empty? + @has_next_page = false + else + @next_page = next_page + true + end end # Retrieves the next page from the API. @@ -36,9 +53,35 @@ def has_next? # @return [Boolean] def get_next return nil if @page_number.nil? - next_page = @get_next_page.call(@page_number) - @page_number = next_page.send(@page_field) - next_page + + # We sometimes preload the next page so that has_next? will be accurate even when we don't have has_next_field. + if @next_page + this_page = @next_page + @next_page = nil + else + this_page = @get_next_page.call(@page_number) + end + + if @has_next_field + @has_next_page = this_page&.send(@has_next_field) + end + + items = this_page.send(@item_field) + if items.nil? || items.empty? + @page_number = nil + return nil + elsif @step + @page_number += items.length + else + @page_number += 1 + end + + this_page + end + + private + + def load_next_page end end end diff --git a/test/square/internal/iterators/test_cursor_item_iterator.rb b/test/square/internal/iterators/test_cursor_item_iterator.rb index dde8cdc0..3b87f2af 100644 --- a/test/square/internal/iterators/test_cursor_item_iterator.rb +++ b/test/square/internal/iterators/test_cursor_item_iterator.rb @@ -30,6 +30,9 @@ def test_item_iterator_can_iterate_to_exhaustion iterator = make_iterator(initial_cursor: 10) assert_equal (11..65).to_a, iterator.to_a + + iterator = make_iterator(initial_cursor: 5) + assert_equal (6..65).to_a, iterator.to_a end def test_item_iterator_can_work_without_an_initial_cursor diff --git a/test/square/internal/iterators/test_offset_item_iterator.rb b/test/square/internal/iterators/test_offset_item_iterator.rb index 747d7478..ce01d5d4 100644 --- a/test/square/internal/iterators/test_offset_item_iterator.rb +++ b/test/square/internal/iterators/test_offset_item_iterator.rb @@ -6,49 +6,124 @@ require "test_helper" require "ostruct" -NUMBERS = (1..65).to_a +TestIteratorConfig = Struct.new( + :step, + :has_next_field, + :total_item_count, + :per_page, + :initial_page, +) do + def first_item_returned + if step + (initial_page || 0) + 1 + else + (((initial_page || 1) - 1) * per_page) + 1 + end + end +end + +LAZY_TEST_ITERATOR_CONFIG = TestIteratorConfig.new(initial_page: 1, step: false, has_next_field: :has_next, total_item_count: 65, per_page: 10) +ALL_TEST_ITERATOR_CONFIGS = [] + +for step in [true, false] + for has_next_field in [:has_next, nil] + for total_item_count in [0, 5, 10, 60, 63] + for per_page in [5, 10] + initial_pages = [nil, 3, 100] + initial_pages << (step ? 0 : 1) + + for initial_page in initial_pages + ALL_TEST_ITERATOR_CONFIGS << TestIteratorConfig.new( + step: step, + has_next_field: has_next_field, + total_item_count: total_item_count, + per_page: per_page, + initial_page: initial_page, + ) + end + end + end + end +end class OffsetItemIteratorTest < Minitest::Test - def make_iterator(initial_page:) + def make_iterator(config) @times_called = 0 - Square::Internal::OffsetItemIterator.new(initial_page:, page_field: :next_page, item_field: :cards) do |page| + items = (1..config.total_item_count).to_a + + Square::Internal::OffsetItemIterator.new( + initial_page: config.initial_page, + item_field: :items, + has_next_field: config.has_next_field, + step: config.step, + ) do |page| @times_called += 1 - next_page = page + 1 - OpenStruct.new( - cards: NUMBERS[((page - 1) * 10)...((next_page - 1) * 10)], - next_page: NUMBERS.length > page * 10 ? next_page : nil - ) + + slice_start = config.step ? page : (page - 1) * config.per_page + slice_end = slice_start + config.per_page + + output = { + items: items[slice_start...slice_end], + } + if config.has_next_field + output[config.has_next_field] = slice_end < items.length + end + + OpenStruct.new(output) end end def test_item_iterator_can_iterate_to_exhaustion - iterator = make_iterator(initial_page: 1) - assert_equal NUMBERS, iterator.to_a - assert_equal 7, @times_called + for config in ALL_TEST_ITERATOR_CONFIGS + iterator = make_iterator(config) + assert_equal (config.first_item_returned..config.total_item_count).to_a, iterator.to_a + end + end + + def test_items_iterator_can_be_advanced_manually_and_has_accurate_has_next + for config in ALL_TEST_ITERATOR_CONFIGS + iterator = make_iterator(config) + items = [] - iterator = make_iterator(initial_page: 2) - assert_equal (11..65).to_a, iterator.to_a + while item = iterator.get_next do + assert_equal(item != config.total_item_count, iterator.has_next?, "#{item} #{iterator}") + items.push(item) + end + + assert_equal (config.first_item_returned..config.total_item_count).to_a, items + end end - def test_item_iterator_can_work_without_an_initial_page - iterator = make_iterator(initial_page: nil) - assert_equal NUMBERS, iterator.to_a - assert_equal 7, @times_called + def test_pages_iterator_can_be_advanced_manually_and_has_accurate_has_next + for config in ALL_TEST_ITERATOR_CONFIGS + iterator = make_iterator(config).pages + pages = [] + + loop do + has_next_output = iterator.has_next? + page = iterator.get_next + assert_equal(has_next_output, !page.nil?, "has_next was inaccurate: #{config} #{iterator.inspect}") + break if page.nil? + pages.push(page) + end + + assert_equal pages, make_iterator(config).pages.to_a + end end def test_items_iterator_iterates_lazily - iterator = make_iterator(initial_page: 1) + iterator = make_iterator(LAZY_TEST_ITERATOR_CONFIG) assert_equal 0, @times_called assert_equal 1, iterator.first assert_equal 1, @times_called - iterator = make_iterator(initial_page: 1) + iterator = make_iterator(LAZY_TEST_ITERATOR_CONFIG) assert_equal 0, @times_called assert_equal (1..15).to_a, iterator.first(15) assert_equal 2, @times_called - iterator = make_iterator(initial_page: 1) + iterator = make_iterator(LAZY_TEST_ITERATOR_CONFIG) assert_equal 0, @times_called iterator.each do |card| if card >= 15 @@ -58,108 +133,15 @@ def test_items_iterator_iterates_lazily assert_equal 2, @times_called end - def test_items_iterator_implements_enumerable - iterator = make_iterator(initial_page: 1) - assert_equal 0, @times_called - doubled = iterator.map{|card| card * 2} - assert_equal 7, @times_called - assert_equal NUMBERS.length, doubled.length - end - - def test_items_iterator_can_be_advanced_manually - iterator = make_iterator(initial_page: 1) - assert_equal 0, @times_called - - items = [] - expected_times_called = 0 - while item = iterator.get_next do - expected_times_called += 1 if (item % 10) == 1 - assert_equal expected_times_called, @times_called - assert_equal item != NUMBERS.last, iterator.has_next?, "#{item} #{iterator}" - items.push(item) - end - - assert_equal 7, @times_called - assert_equal NUMBERS, items - end - - def test_pages_iterator - iterator = make_iterator(initial_page: 1).pages - assert_equal( - [ - (1..10).to_a, - (11..20).to_a, - (21..30).to_a, - (31..40).to_a, - (41..50).to_a, - (51..60).to_a, - (61..65).to_a, - ], - iterator.to_a.map{|p| p.cards} - ) - - iterator = make_iterator(initial_page: 2).pages - assert_equal( - [ - (11..20).to_a, - (21..30).to_a, - (31..40).to_a, - (41..50).to_a, - (51..60).to_a, - (61..65).to_a, - ], - iterator.to_a.map{|p| p.cards} - ) - end - - def test_pages_iterator_can_work_without_an_initial_page - iterator = make_iterator(initial_page: nil).pages - assert_equal 7, iterator.to_a.length - assert_equal 7, @times_called - end - def test_pages_iterator_iterates_lazily - iterator = make_iterator(initial_page: 1).pages + iterator = make_iterator(LAZY_TEST_ITERATOR_CONFIG).pages assert_equal 0, @times_called iterator.first assert_equal 1, @times_called - iterator = make_iterator(initial_page: 1).pages - assert_equal 0, @times_called - assert_equal 2, iterator.first(2).length - assert_equal 2, @times_called - end - - def test_pages_iterator_knows_whether_another_page_is_upcoming - iterator = make_iterator(initial_page: 1).pages - - iterator.each_with_index do |page, index| - assert_equal index + 1, @times_called - assert_equal index < 6, iterator.has_next? - end - end - - def test_pages_iterator_can_be_advanced_manually - iterator = make_iterator(initial_page: 1).pages - assert_equal 0, @times_called - - lengths = [] - expected_times_called = 0 - while page = iterator.get_next do - expected_times_called += 1 - assert_equal expected_times_called, @times_called - lengths.push(page.cards.length) - end - - assert_equal 7, @times_called - assert_equal [10, 10, 10, 10, 10, 10, 5], lengths - end - - def test_pages_iterator_implements_enumerable - iterator = make_iterator(initial_page: 1).pages + iterator = make_iterator(LAZY_TEST_ITERATOR_CONFIG).pages assert_equal 0, @times_called - lengths = iterator.map{|page| page.cards.length} - assert_equal 7, @times_called - assert_equal [10, 10, 10, 10, 10, 10, 5], lengths + assert_equal 3, iterator.first(3).length + assert_equal 3, @times_called end end From 69eaad560a15a280fca893a6ff313ebe8d9e6a88 Mon Sep 17 00:00:00 2001 From: Chris Hanks Date: Fri, 19 Sep 2025 12:04:23 -0400 Subject: [PATCH 22/22] Cleanup. --- lib/square/internal/iterators/offset_item_iterator.rb | 1 + lib/square/internal/iterators/offset_page_iterator.rb | 7 +------ 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/square/internal/iterators/offset_item_iterator.rb b/lib/square/internal/iterators/offset_item_iterator.rb index e24bc9e9..b7fdb262 100644 --- a/lib/square/internal/iterators/offset_item_iterator.rb +++ b/lib/square/internal/iterators/offset_item_iterator.rb @@ -6,6 +6,7 @@ class OffsetItemIterator < ItemIterator # @param initial_page [Integer] The initial cursor to use when iterating. # @param item_field [Symbol] The name of the field in API responses to extract the items to iterate over. # @param has_next_field [Symbol] The name of the field in API responses containing a boolean of whether another page exists. + # @param step [Boolean] If true, treats the page number as a true offset (i.e. increments the page number by the number of items returned from each call rather than just 1) def initialize(initial_page:, item_field:, has_next_field:, step:, &block) @item_field = item_field @page_iterator = OffsetPageIterator.new(initial_page:, item_field:, has_next_field:, step:, &block) diff --git a/lib/square/internal/iterators/offset_page_iterator.rb b/lib/square/internal/iterators/offset_page_iterator.rb index 78633748..beb7a448 100644 --- a/lib/square/internal/iterators/offset_page_iterator.rb +++ b/lib/square/internal/iterators/offset_page_iterator.rb @@ -9,7 +9,7 @@ class OffsetPageIterator # @param item_field [Symbol] The field to pull the list of items to iterate over. # @param has_next_field [Symbol] The field to pull the boolean of whether a next page exists from, if any. # @param step [Boolean] If true, treats the page number as a true offset (i.e. increments the page number by the number of items returned from each call rather than just 1) - # @return [Square::Internal::PageIterator] + # @return [Square::Internal::OffsetPageIterator] def initialize(initial_page:, item_field:, has_next_field:, step:, &block) @page_number = initial_page || (step ? 0 : 1) @item_field = item_field @@ -78,11 +78,6 @@ def get_next this_page end - - private - - def load_next_page - end end end end