Skip to content

Commit bae5730

Browse files
authored
Pagination (#289)
1 parent a081347 commit bae5730

28 files changed

Lines changed: 802 additions & 51 deletions

File tree

gems/smithy-client/lib/smithy-client.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
require_relative 'smithy-client/handler_list_entry'
1919
require_relative 'smithy-client/managed_file'
2020
require_relative 'smithy-client/networking_error'
21+
require_relative 'smithy-client/pageable_output'
2122
require_relative 'smithy-client/param_converter'
2223
require_relative 'smithy-client/param_validator'
2324
require_relative 'smithy-client/plugin'

gems/smithy-client/lib/smithy-client/errors.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@
33
module Smithy
44
module Client
55
module Errors
6+
# Raised when calling {PageableOutput#next_page} on a paginator that
7+
# is on the last page of results. You can call {PageableOutput#last_page?}
8+
# or {PageableOutput#next_page?} to know if there are more pages.
9+
class LastPageError < RuntimeError
10+
# @param [Output] output
11+
def initialize(output)
12+
@output = output
13+
super('unable to fetch next page, end of results reached')
14+
end
15+
16+
# @return [Output]
17+
attr_reader :output
18+
end
19+
620
# The base class for all errors returned by a Smithy generated client.
721
# All ~400 level client errors and ~500 level server errors are raised
822
# as service errors. This indicates it was an error returned from the

gems/smithy-client/lib/smithy-client/output.rb

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def initialize(options = {})
1616
@request = @context.request
1717
@response = @context.response
1818
@response.on_error { |error| @error = error }
19-
super(@data)
19+
super(@error || @data)
2020
end
2121

2222
# @return [HandlerContext]
@@ -32,16 +32,18 @@ def initialize(options = {})
3232

3333
# Necessary to define as a subclass of Delegator
3434
# @api private
35-
def __getobj__
36-
return yield if block_given? && !defined?(@data)
37-
38-
@data
35+
def __getobj__(&)
36+
@error || @data
3937
end
4038

4139
# Necessary to define as a subclass of Delegator
4240
# @api private
4341
def __setobj__(obj)
44-
@data = obj
42+
if obj.is_a?(StandardError)
43+
@error = obj
44+
else
45+
@data = obj
46+
end
4547
end
4648
end
4749
end
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# frozen_string_literal: true
2+
3+
module Smithy
4+
module Client
5+
# Decorates a {Smithy::Client::Output} with paging convenience methods.
6+
# Most API calls provide paged responses to limit the amount of data returned
7+
# with each response. To optimize for latency, some APIs may return an
8+
# inconsistent number of responses per page. You should rely on the values of
9+
# the `next_page?` method or using enumerable methods such as `each_page` rather
10+
# than the number of items returned to iterate through results. See below for
11+
# examples.
12+
#
13+
# # Enumerator Methods
14+
# The simplest way to handle paged response data is to use the built-in
15+
# `each_page` enumerator on the output object:
16+
#
17+
# weather = Weather::Client.new
18+
# weather.list_cities.each_page do |page|
19+
# puts page.items.map(&:name)
20+
# end
21+
#
22+
# This yields one output object per API call made. The SDK retrieves additional
23+
# pages of data to complete the request.
24+
#
25+
# If the operation allows for it, a selected item can be enumerated using
26+
# `each_item`:
27+
#
28+
# weather = Weather::Client.new
29+
# weather.list_cities.each_item do |item|
30+
# puts item.name
31+
# end
32+
#
33+
# # Handling Paged Output Manually
34+
# To handle paging yourself, use the output's `next_page?` method to verify
35+
# there are more pages to retrieve, or use the `last_page?` method to verify
36+
# there are no more pages to retrieve.
37+
#
38+
# If there are more pages, use the `next_page` method to retrieve the
39+
# next page of results, as shown in the following example.
40+
#
41+
# weather = Weather::Client.new
42+
#
43+
# # Get the first page of data
44+
# output = weather.list_cities
45+
#
46+
# # Get additional pages
47+
# while output.next_page?
48+
# output = output.next_page
49+
# # Use the response data here...
50+
# puts output.items.map(&:name)
51+
# end
52+
#
53+
module PageableOutput
54+
# @api private
55+
attr_accessor :paginator
56+
57+
# Returns `true` if there are no more results. Calling {#next_page}
58+
# when this method returns `false` will raise an error.
59+
# @return [Boolean]
60+
def last_page?
61+
return @last_page if @last_page
62+
63+
@last_page = !truncated?
64+
end
65+
66+
# Returns `true` if there are more results. Calling {#next_page} will
67+
# return the next response.
68+
# @return [Boolean]
69+
def next_page?
70+
return @next_page if @next_page
71+
72+
@next_page = truncated?
73+
end
74+
75+
# @param [Hash] params A hash of additional request params.
76+
# @return [Output] Returns the next page of results.
77+
def next_page(params = {})
78+
raise Errors::LastPageError, self if last_page?
79+
80+
params = next_page_params(params)
81+
context.client.send(context.operation_name, params)
82+
end
83+
84+
# Yields the current and each following output to the given block.
85+
# @yieldparam [Output] output
86+
# @return [Enumerable, nil] Returns a new Enumerable if no block is given.
87+
def each_page(&)
88+
output = self
89+
yield(output)
90+
until output.last_page?
91+
output = output.next_page
92+
yield(output)
93+
end
94+
end
95+
96+
# Yields the current and each following item to the given block.
97+
# @yieldparam [Object] item
98+
# @return [Enumerable, nil] Returns a new Enumerable if no block is given.
99+
def each_item(&)
100+
output = self
101+
@paginator.items(output.data).each(&)
102+
until output.last_page?
103+
output = output.next_page
104+
@paginator.items(output.data).each(&)
105+
end
106+
end
107+
108+
private
109+
110+
def truncated?
111+
next_t = @paginator.next_tokens(data)
112+
!(next_t.empty? || next_t == @paginator.prev_tokens(context.params))
113+
end
114+
115+
def next_page_params(params)
116+
prev_tokens = @paginator.prev_tokens(context.params)
117+
# Remove all previous tokens from original params
118+
# Sometimes a token can be nil and merge would not include it.
119+
new_params = context.params.except(*prev_tokens)
120+
new_params.merge!(@paginator.next_tokens(data).merge(params))
121+
end
122+
end
123+
end
124+
end
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# frozen_string_literal: true
2+
3+
module Smithy
4+
module Client
5+
module Plugins
6+
# @api private
7+
class PageableOutput < Plugin
8+
# @api private
9+
class Handler < Client::Handler
10+
def call(context)
11+
output = @handler.call(context)
12+
output.extend(Client::PageableOutput)
13+
output.paginator = context.operation[:paginator] || NullPaginator.new
14+
output
15+
end
16+
17+
# @api private
18+
class NullPaginator
19+
def next_tokens(_data)
20+
{}
21+
end
22+
23+
def prev_tokens(_params)
24+
{}
25+
end
26+
27+
def items(_data)
28+
raise NotImplementedError, 'item iteration is not implemented for this operation'
29+
end
30+
end
31+
end
32+
33+
handler(Handler, step: :initialize, priority: 95)
34+
end
35+
end
36+
end
37+
end

gems/smithy-client/sig/smithy-client/output.rbs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ module Smithy
22
module Client
33
# RBS does not support Delegator.
44
# the behavior mimics `Smithy::Client::Output` as much as possible.
5-
interface _Output[DATA]
5+
interface _Output[DATA_OR_ERROR]
66
def context: () -> HandlerContext?
7-
def data: () -> DATA?
8-
def error: () -> StandardError?
7+
def data: () -> DATA_OR_ERROR?
8+
def error: () -> DATA_OR_ERROR?
99
end
1010
end
1111
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module Smithy
2+
module Client
3+
interface _PageableOutput[DATA]
4+
def last_page?: () -> bool
5+
def next_page?: () -> bool
6+
def next_page: (Hash[untyped, untyped] params) -> _PageableOutput[DATA]?
7+
def each_page: () -> Enumerator[_PageableOutput[DATA]]?
8+
def each_item: () -> Enumerator[untyped]?
9+
end
10+
end
11+
end

gems/smithy-client/spec/smithy-client/output_spec.rb

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,39 @@ module Client
1111
expect(subject).to be_kind_of(Delegator)
1212
end
1313

14-
it 'delegates to the data with a getter' do
14+
it 'delegates to an error with the getter' do
15+
error = StandardError.new
16+
subject.error = error
17+
expect(subject.__getobj__).to be(error)
18+
end
19+
20+
it 'delegates to the data with the getter' do
1521
data = double('data')
1622
subject.data = data
1723
expect(subject.__getobj__).to be(data)
1824
end
1925

20-
it 'delegates to the data with a setter' do
26+
it 'prefers the error over the data' do
27+
error = StandardError.new
28+
data = double('data')
29+
subject.error = error
30+
subject.data = data
31+
expect(subject.__getobj__).to be(error)
32+
end
33+
34+
it 'sets the delegated error with a setter' do
35+
error = StandardError.new
36+
subject.__setobj__(error)
37+
expect(subject.error).to be(error)
38+
end
39+
40+
it 'sets the error with a setter only if the object is a StandardError' do
41+
error = double('error')
42+
subject.__setobj__(error)
43+
expect(subject.error).to be(nil)
44+
end
45+
46+
it 'sets the delegated data with a setter' do
2147
data = double('data')
2248
subject.__setobj__(data)
2349
expect(subject.data).to be(data)

0 commit comments

Comments
 (0)