x-resources 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +254 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/lib/x/resources/abstract_class.rb +29 -0
- data/lib/x/resources/actions/direct_messages.rb +99 -0
- data/lib/x/resources/actions/engagement.rb +95 -0
- data/lib/x/resources/actions/lists.rb +126 -0
- data/lib/x/resources/actions/posts.rb +77 -0
- data/lib/x/resources/actions/relationships.rb +91 -0
- data/lib/x/resources/actions.rb +22 -0
- data/lib/x/resources/api.rb +40 -0
- data/lib/x/resources/attributes.rb +203 -0
- data/lib/x/resources/batch.rb +43 -0
- data/lib/x/resources/batch_finders.rb +185 -0
- data/lib/x/resources/bookmark_folder.rb +23 -0
- data/lib/x/resources/community.rb +137 -0
- data/lib/x/resources/cursor.rb +493 -0
- data/lib/x/resources/direct_message.rb +325 -0
- data/lib/x/resources/direct_message_conversations.rb +147 -0
- data/lib/x/resources/errors.rb +104 -0
- data/lib/x/resources/finders.rb +255 -0
- data/lib/x/resources/identity.rb +65 -0
- data/lib/x/resources/includes.rb +216 -0
- data/lib/x/resources/list.rb +336 -0
- data/lib/x/resources/lookups/communities.rb +56 -0
- data/lib/x/resources/lookups/direct_messages.rb +86 -0
- data/lib/x/resources/lookups/lists.rb +44 -0
- data/lib/x/resources/lookups/media.rb +72 -0
- data/lib/x/resources/lookups/posts.rb +198 -0
- data/lib/x/resources/lookups/spaces.rb +87 -0
- data/lib/x/resources/lookups/trends.rb +38 -0
- data/lib/x/resources/lookups/users.rb +221 -0
- data/lib/x/resources/lookups.rb +25 -0
- data/lib/x/resources/marshalling.rb +93 -0
- data/lib/x/resources/matching_rule.rb +107 -0
- data/lib/x/resources/media.rb +278 -0
- data/lib/x/resources/media_ids.rb +74 -0
- data/lib/x/resources/memo.rb +54 -0
- data/lib/x/resources/page.rb +394 -0
- data/lib/x/resources/page_limit.rb +80 -0
- data/lib/x/resources/pages.rb +270 -0
- data/lib/x/resources/parallel.rb +82 -0
- data/lib/x/resources/personalized_trend.rb +124 -0
- data/lib/x/resources/place.rb +107 -0
- data/lib/x/resources/poll.rb +75 -0
- data/lib/x/resources/post.rb +615 -0
- data/lib/x/resources/post_collections.rb +67 -0
- data/lib/x/resources/post_counts.rb +215 -0
- data/lib/x/resources/post_search.rb +86 -0
- data/lib/x/resources/post_usage.rb +203 -0
- data/lib/x/resources/post_writes.rb +140 -0
- data/lib/x/resources/published_count.rb +31 -0
- data/lib/x/resources/references.rb +121 -0
- data/lib/x/resources/relation_writes.rb +54 -0
- data/lib/x/resources/relationships.rb +77 -0
- data/lib/x/resources/resource.rb +535 -0
- data/lib/x/resources/serialization.rb +58 -0
- data/lib/x/resources/shape.rb +167 -0
- data/lib/x/resources/space.rb +332 -0
- data/lib/x/resources/topic.rb +59 -0
- data/lib/x/resources/trend.rb +130 -0
- data/lib/x/resources/user.rb +502 -0
- data/lib/x/resources/user_collections.rb +213 -0
- data/lib/x/resources/user_finders.rb +282 -0
- data/lib/x/resources/utils.rb +358 -0
- data/lib/x/resources/value_equality.rb +38 -0
- data/lib/x/resources/value_marshalling.rb +89 -0
- data/lib/x/resources/version.rb +25 -0
- data/lib/x/resources.rb +22 -0
- data/sig/manifest.yaml +7 -0
- data/sig/x-resources.rbs +813 -0
- metadata +140 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "shape"
|
|
6
|
+
|
|
7
|
+
module X
|
|
8
|
+
module Resources
|
|
9
|
+
# One page of results from a paginated endpoint
|
|
10
|
+
# @api public
|
|
11
|
+
class ::X::Page
|
|
12
|
+
include Enumerable
|
|
13
|
+
|
|
14
|
+
# The number of the format of the state Marshal writes, which every release of 1.x writes
|
|
15
|
+
#
|
|
16
|
+
# A later release of 1.x adds to the state only what an earlier one ignores, parts after those it reads and keys of a
|
|
17
|
+
# Hash it does not read, so that the state one release of 1.x writes is read by every other, earlier or later.
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
20
|
+
MARSHAL_FORMAT = 1
|
|
21
|
+
# The name YAML writes each part of the state under, in the order Marshal writes them
|
|
22
|
+
# @api private
|
|
23
|
+
YAML_KEYS = %w[format resources meta problems includes].freeze
|
|
24
|
+
# The query parameters that name the resources of a lookup of several, which a problem of a response names when
|
|
25
|
+
# it found none of them
|
|
26
|
+
# @api private
|
|
27
|
+
LIST_PARAMETERS = %w[ids media_keys usernames].freeze
|
|
28
|
+
private_constant :MARSHAL_FORMAT, :YAML_KEYS, :LIST_PARAMETERS
|
|
29
|
+
|
|
30
|
+
# The resources on this page
|
|
31
|
+
# @api public
|
|
32
|
+
# @return [Array<Resource>] the resources
|
|
33
|
+
# @example Get the resources on a page
|
|
34
|
+
# page.items
|
|
35
|
+
attr_reader :items
|
|
36
|
+
|
|
37
|
+
# The problems the response of this page reported
|
|
38
|
+
#
|
|
39
|
+
# They are every problem of the response, where each resource of the page reports only those about it, or about
|
|
40
|
+
# a resource it refers to.
|
|
41
|
+
#
|
|
42
|
+
# @api public
|
|
43
|
+
# @return [Array<Problem>] the problems
|
|
44
|
+
# @example Collect every problem a cursor's pages reported
|
|
45
|
+
# user.followers.each_page.flat_map(&:problems)
|
|
46
|
+
attr_reader :problems
|
|
47
|
+
|
|
48
|
+
# The pagination metadata returned with this page
|
|
49
|
+
# @api public
|
|
50
|
+
# @return [Hash{String => Object}] the metadata
|
|
51
|
+
# @example Get the metadata
|
|
52
|
+
# page.meta # => {"result_count" => 100, "next_token" => "..."}
|
|
53
|
+
attr_reader :meta
|
|
54
|
+
|
|
55
|
+
# Initialize a new page
|
|
56
|
+
#
|
|
57
|
+
# @api public
|
|
58
|
+
# @param items [Array<Resource>] the resources on the page
|
|
59
|
+
# @param meta [Hash] the pagination metadata, empty by default, as for a page whose response holds none
|
|
60
|
+
# @param problems [Array<Problem>] the problems the page's response reported
|
|
61
|
+
# @return [Page] a new page, which holds frozen copies of the arrays it is given, leaving them as they are
|
|
62
|
+
# @raise [ArgumentError] if the items are not an Array of resources, the metadata is not a Hash, or the problems
|
|
63
|
+
# are not an Array of problems
|
|
64
|
+
# @example Create a page
|
|
65
|
+
# X::Page.new([user], meta: {"result_count" => 1})
|
|
66
|
+
def initialize(items, meta: {}, problems: [])
|
|
67
|
+
@items = resources!(items).dup.freeze
|
|
68
|
+
@meta = Utils.deep_freeze(Hash.try_convert(meta) || raise(ArgumentError, "meta must be a Hash, not #{meta.inspect}"))
|
|
69
|
+
@problems = problems!(problems).dup.freeze
|
|
70
|
+
freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Iterate over the resources on this page
|
|
74
|
+
#
|
|
75
|
+
# @api public
|
|
76
|
+
# @yield [Resource] each resource
|
|
77
|
+
# @return [Enumerator, Page] an enumerator without a block, otherwise self, as a cursor returns itself
|
|
78
|
+
# @example Iterate over a page
|
|
79
|
+
# page.each { |user| puts user.username }
|
|
80
|
+
def each(&block)
|
|
81
|
+
return to_enum { size } unless block
|
|
82
|
+
|
|
83
|
+
items.each(&block)
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The resources on this page, frozen, as {#items} returns them
|
|
88
|
+
#
|
|
89
|
+
# @api public
|
|
90
|
+
# @return [Array<Resource>] the resources
|
|
91
|
+
# @example Get the resources on a page as an Array
|
|
92
|
+
# page.to_a
|
|
93
|
+
def to_a = items
|
|
94
|
+
|
|
95
|
+
alias_method :entries, :to_a
|
|
96
|
+
|
|
97
|
+
# The number of resources on this page
|
|
98
|
+
#
|
|
99
|
+
# @api public
|
|
100
|
+
# @return [Integer] the number of resources
|
|
101
|
+
# @example Count the resources on a page
|
|
102
|
+
# page.size # => 100
|
|
103
|
+
def size = items.size
|
|
104
|
+
|
|
105
|
+
alias_method :length, :size
|
|
106
|
+
|
|
107
|
+
# Check whether this page holds no resources
|
|
108
|
+
#
|
|
109
|
+
# @api public
|
|
110
|
+
# @return [Boolean] true if the page holds none
|
|
111
|
+
# @example Stop at an empty page
|
|
112
|
+
# break if page.empty?
|
|
113
|
+
def empty? = items.empty?
|
|
114
|
+
|
|
115
|
+
# The resource at an index, or the resources of a range, as Array#[] reads them
|
|
116
|
+
#
|
|
117
|
+
# @api public
|
|
118
|
+
# @param args [Array<Integer, Range>] an index, a start and a length, or a range
|
|
119
|
+
# @return [Resource, Array<Resource>, nil] the resource, or the resources, or nil for an index past the end
|
|
120
|
+
# @example Get the first resource on a page
|
|
121
|
+
# page[0]
|
|
122
|
+
def [](*args) = items[*args] # steep:ignore DifferentMethodParameterKind, UnresolvedOverloading
|
|
123
|
+
|
|
124
|
+
# The last resource on this page, or the last few
|
|
125
|
+
#
|
|
126
|
+
# @api public
|
|
127
|
+
# @param args [Array<Integer>] nothing for the last resource, or the number of resources to take from the end
|
|
128
|
+
# @return [Resource, Array<Resource>, nil] the last resource, or the last resources, or nil for an empty page
|
|
129
|
+
# @example Get the last resource on a page
|
|
130
|
+
# page.last
|
|
131
|
+
def last(*args) = items.last(*args) # steep:ignore DifferentMethodParameterKind, UnresolvedOverloading
|
|
132
|
+
|
|
133
|
+
# Check whether another page is the same page
|
|
134
|
+
#
|
|
135
|
+
# Its resources are compared as resources are, by class and identifier, so a page read again, or read back from
|
|
136
|
+
# Marshal, equals the page it was read from.
|
|
137
|
+
#
|
|
138
|
+
# @api public
|
|
139
|
+
# @param other [Object] the other page
|
|
140
|
+
# @return [Boolean] true if the other page is a Page of the same resources, in the same order, with the same meta
|
|
141
|
+
# and problems
|
|
142
|
+
# @example Check whether a page was read before
|
|
143
|
+
# seen.include?(page)
|
|
144
|
+
def ==(other) = other.instance_of?(self.class) && state.eql?(other.__send__(:state))
|
|
145
|
+
alias_method :eql?, :==
|
|
146
|
+
|
|
147
|
+
# The hash of the page, which equal pages share
|
|
148
|
+
#
|
|
149
|
+
# @api public
|
|
150
|
+
# @return [Integer] the hash
|
|
151
|
+
# @example Count the distinct pages
|
|
152
|
+
# pages.uniq.size
|
|
153
|
+
def hash = [self.class, state].hash
|
|
154
|
+
|
|
155
|
+
# The token used to fetch the next page
|
|
156
|
+
#
|
|
157
|
+
# An empty token names no page, so a page whose meta holds one is the last, as a page whose meta holds none is,
|
|
158
|
+
# rather than one whose next page is fetched with an empty token the API refuses.
|
|
159
|
+
#
|
|
160
|
+
# @api public
|
|
161
|
+
# @return [String, nil] the token or nil if this is the last page
|
|
162
|
+
# @example Get the next token
|
|
163
|
+
# page.next_token
|
|
164
|
+
def next_token
|
|
165
|
+
token = meta["next_token"]
|
|
166
|
+
token unless token.eql?("")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The token used to fetch the page before this one
|
|
170
|
+
#
|
|
171
|
+
# Most endpoints that page, such as the followers of a user, the members of a list, and the events of a direct
|
|
172
|
+
# message conversation, name the page before each page after the first. An empty token names no page, as an
|
|
173
|
+
# empty next_token does.
|
|
174
|
+
#
|
|
175
|
+
# @api public
|
|
176
|
+
# @return [String, nil] the token or nil if this is the first page, or the endpoint names none
|
|
177
|
+
# @example Fetch the page before this one
|
|
178
|
+
# client.get("users/7505382/followers?pagination_token=#{page.previous_token}", object_class: X::User)
|
|
179
|
+
def previous_token
|
|
180
|
+
token = meta["previous_token"]
|
|
181
|
+
token unless token.eql?("")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The number of results reported by the API
|
|
185
|
+
#
|
|
186
|
+
# @api public
|
|
187
|
+
# @return [Integer, nil] the result count
|
|
188
|
+
# @raise [InvalidAttribute] if the meta holds a result count that is not a number
|
|
189
|
+
# @example Get the result count
|
|
190
|
+
# page.result_count
|
|
191
|
+
def result_count
|
|
192
|
+
Utils.read("#{self.class}#result_count", meta["result_count"]) { |value| Shape.integer(value) }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# This page, as a JSON encoder reads it, in the shape of the response it came from
|
|
196
|
+
#
|
|
197
|
+
# Its resources are the data, each given as its own as_json gives it, beside the meta of the page, which holds
|
|
198
|
+
# the token of the next, and, when the response reported any, its problems as the errors, so that what this
|
|
199
|
+
# returns is plain data, which ActiveSupport reads too, and which the from_response of the resource class builds
|
|
200
|
+
# into a page again. The objects the response included are not among it, so a reference of a resource built
|
|
201
|
+
# again from it is a stub.
|
|
202
|
+
#
|
|
203
|
+
# @api public
|
|
204
|
+
# @return [Hash{String => Object}] the data, meta, and errors of the page, frozen
|
|
205
|
+
# @example Serialize a page
|
|
206
|
+
# page.as_json # => {"data" => [{"id" => "7505382"}], "meta" => {"next_token" => "abc"}}
|
|
207
|
+
# @example Build a page again from what it serialized to
|
|
208
|
+
# X::User.from_response(JSON.parse(page.to_json), client: client)
|
|
209
|
+
def as_json(*)
|
|
210
|
+
json = {"data" => map(&:as_json), "meta" => meta}
|
|
211
|
+
json["errors"] = problems.map(&:to_h) unless problems.empty?
|
|
212
|
+
json.freeze
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# This page as a Hash in the shape of its response, or of a pair for each resource
|
|
216
|
+
#
|
|
217
|
+
# Without a block it is {#as_json}, as the to_h of a resource is its attributes, rather than the to_h of
|
|
218
|
+
# Enumerable, which raises TypeError for resources that are not pairs. With a block it is the to_h of Enumerable,
|
|
219
|
+
# which builds a Hash of the pair the block returns for each resource.
|
|
220
|
+
#
|
|
221
|
+
# @api public
|
|
222
|
+
# @yieldparam resource [Resource] each resource
|
|
223
|
+
# @yieldreturn [Array(Object, Object)] the key and value of the resource
|
|
224
|
+
# @return [Hash] the data, meta, and errors of the page, frozen, or the pairs the block returns
|
|
225
|
+
# @example Get the page in the shape of its response
|
|
226
|
+
# page.to_h # => {"data" => [{"id" => "7505382"}], "meta" => {"next_token" => "abc"}}
|
|
227
|
+
# @example Index the users of a page by username
|
|
228
|
+
# page.to_h { |user| [user.username, user] }
|
|
229
|
+
def to_h(&block) = block ? super() : as_json
|
|
230
|
+
|
|
231
|
+
# This page as a JSON object in the shape of the response it came from
|
|
232
|
+
#
|
|
233
|
+
# @api public
|
|
234
|
+
# @param state [JSON::State, nil] the state a JSON encoder passes, which the attributes are given
|
|
235
|
+
# @return [String] the data, meta, and errors of the page as a JSON object
|
|
236
|
+
# @example Serialize a page
|
|
237
|
+
# page.to_json # => "{\"data\":[{\"id\":\"7505382\"}],\"meta\":{}}"
|
|
238
|
+
def to_json(state = nil) = as_json.to_json(state)
|
|
239
|
+
|
|
240
|
+
# The meta of a response, which holds the token of its next page
|
|
241
|
+
#
|
|
242
|
+
# A cursor and the from_response of a resource class read the meta of the pages they build with it, so that a
|
|
243
|
+
# meta that is not an object raises alike, rather than end the paging of one of them without a word.
|
|
244
|
+
#
|
|
245
|
+
# @api private
|
|
246
|
+
# @param body [Hash, nil] the parsed response body
|
|
247
|
+
# @return [Hash{String => Object}] the meta, empty if the response holds none
|
|
248
|
+
# @raise [InvalidAttribute] if the response holds a meta that is not an object
|
|
249
|
+
# @example Read the meta of a response
|
|
250
|
+
# X::Page.__send__(:meta_of, {"meta" => {"next_token" => "abc"}}) # => {"next_token" => "abc"}
|
|
251
|
+
def self.meta_of(body) = Shape.read_object("#{self}#meta", body.to_h["meta"]) || {}
|
|
252
|
+
private_class_method :meta_of
|
|
253
|
+
|
|
254
|
+
# Whether a response holds a list
|
|
255
|
+
#
|
|
256
|
+
# The from_response of a resource class builds a page of a response that holds one.
|
|
257
|
+
#
|
|
258
|
+
# A response holds one when its data is an array, and when it holds no data and either a meta, which must then
|
|
259
|
+
# be an object, or a problem that names a parameter of a lookup of several, as one that found none of them does.
|
|
260
|
+
#
|
|
261
|
+
# @api private
|
|
262
|
+
# @param body [Hash] the parsed response body
|
|
263
|
+
# @return [Boolean] true if the response holds a list
|
|
264
|
+
# @example Ask whether an empty list is one
|
|
265
|
+
# X::Page.__send__(:list?, {"meta" => {"result_count" => 0}}) # => true
|
|
266
|
+
def self.list?(body)
|
|
267
|
+
data = body["data"]
|
|
268
|
+
data.is_a?(Array) || (data.nil? && (body.key?("meta") || Problem.all_from(body).any? { |problem| LIST_PARAMETERS.include?(problem.parameter) }))
|
|
269
|
+
end
|
|
270
|
+
private_class_method :list?
|
|
271
|
+
|
|
272
|
+
# The state Marshal writes
|
|
273
|
+
#
|
|
274
|
+
# What is written is plain data, led by the number of its format, so that a page written by one release of 1.x is
|
|
275
|
+
# read by a later one: each resource, as its class, its attributes, and whether it is hydrated, without its client;
|
|
276
|
+
# the included objects the resources refer to, once for the resources that came from one response, as a resource
|
|
277
|
+
# writes those it refers to, so that the resources that resolved a reference to the same object still do; its
|
|
278
|
+
# metadata; and its problems.
|
|
279
|
+
#
|
|
280
|
+
# @api public
|
|
281
|
+
# @return [Array] the number of the format, then the state of the page
|
|
282
|
+
# @example Cache a page
|
|
283
|
+
# Rails.cache.write("followers", user.followers.page(0))
|
|
284
|
+
def marshal_dump
|
|
285
|
+
responses = group_by { |item| response_of(item) }
|
|
286
|
+
[MARSHAL_FORMAT, resource_states(responses.keys), meta, problems, responses.map { |includes, members| includes.state_of(members) }]
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Restore a page Marshal read, frozen as the page that was written was
|
|
290
|
+
#
|
|
291
|
+
# The resources that came from one response are built over one identity map again, so a reference they share
|
|
292
|
+
# resolves to the same object, as it did before the page was written. Each is hydrated if it was, and the query of
|
|
293
|
+
# its request asks for every field this release requests, as a resource Marshal reads is.
|
|
294
|
+
#
|
|
295
|
+
# @api public
|
|
296
|
+
# @param state [Array] the state Marshal wrote
|
|
297
|
+
# @return [void]
|
|
298
|
+
# @raise [UnsupportedFormat] if the state is of a format this release does not read
|
|
299
|
+
# @example Read a cached page
|
|
300
|
+
# Marshal.load(Marshal.dump(page)).next_token
|
|
301
|
+
def marshal_load(state)
|
|
302
|
+
format, resources, meta, problems, responses = state
|
|
303
|
+
raise UnsupportedFormat, "#{self.class} reads format #{MARSHAL_FORMAT} of Marshal, not #{format.inspect}" unless MARSHAL_FORMAT.eql?(format)
|
|
304
|
+
|
|
305
|
+
responses = responses.map { |data, about, query| Includes.new(data, problems: about, query:) }
|
|
306
|
+
initialize(resources.map { |klass, attrs, hydrated, response| read(klass, attrs, hydrated, responses.fetch(response)) }, meta:, problems:)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Write the state Marshal writes as YAML, without the clients of the resources
|
|
310
|
+
#
|
|
311
|
+
# YAML reads no marshal_dump, and would write every instance variable of each resource, its client and the
|
|
312
|
+
# credentials it holds among them, so a page says how it is written: each part of the state Marshal writes, under
|
|
313
|
+
# its name.
|
|
314
|
+
#
|
|
315
|
+
# @api public
|
|
316
|
+
# @param coder [Psych::Coder] the coder YAML writes the page with
|
|
317
|
+
# @return [void]
|
|
318
|
+
# @example Write a page as YAML
|
|
319
|
+
# YAML.dump(user.followers.page(0))
|
|
320
|
+
def encode_with(coder) = YAML_KEYS.zip(marshal_dump) { |key, value| coder[key] = value }
|
|
321
|
+
|
|
322
|
+
# Restore a page YAML read, frozen, as Marshal restores one
|
|
323
|
+
#
|
|
324
|
+
# @api public
|
|
325
|
+
# @param coder [Psych::Coder] the coder YAML read the page with
|
|
326
|
+
# @return [void]
|
|
327
|
+
# @raise [UnsupportedFormat] if the state is of a format this release does not read
|
|
328
|
+
# @example Read a page written as YAML
|
|
329
|
+
# YAML.unsafe_load(YAML.dump(page)).next_token
|
|
330
|
+
def init_with(coder) = marshal_load(coder.map.values_at(*YAML_KEYS))
|
|
331
|
+
|
|
332
|
+
private
|
|
333
|
+
|
|
334
|
+
# What a page is compared by: its resources, meta, and problems
|
|
335
|
+
# @api private
|
|
336
|
+
# @return [Array(Array<Resource>, Hash, Array<Problem>)] the resources, meta, and problems
|
|
337
|
+
def state = [items, meta, problems]
|
|
338
|
+
|
|
339
|
+
# The identity map of the response a resource came from
|
|
340
|
+
# @api private
|
|
341
|
+
# @param resource [Resource] the resource
|
|
342
|
+
# @return [Resources::Includes] the identity map
|
|
343
|
+
def response_of(resource) = resource.__send__(:includes)
|
|
344
|
+
|
|
345
|
+
# Build a resource Marshal read of the page, over the identity map of its response
|
|
346
|
+
# @api private
|
|
347
|
+
# @param klass [Class] the resource class
|
|
348
|
+
# @param attrs [Hash] the attributes
|
|
349
|
+
# @param hydrated [Boolean] whether the resource was hydrated as it was written
|
|
350
|
+
# @param includes [Resources::Includes] the identity map of its response
|
|
351
|
+
# @return [Resource] the resource
|
|
352
|
+
def read(klass, attrs, hydrated, includes) = klass.__send__(:build, attrs, includes:, hydrated: includes.hydrated_as_read?(klass, hydrated))
|
|
353
|
+
|
|
354
|
+
# What Marshal writes of each resource of the page
|
|
355
|
+
#
|
|
356
|
+
# It is the class of the resource, its attributes, whether it is hydrated, and which response it came from.
|
|
357
|
+
#
|
|
358
|
+
# @api private
|
|
359
|
+
# @param responses [Array<Resources::Includes>] the identity map of each response the resources came from
|
|
360
|
+
# @return [Array<Array(Class, Hash, Boolean, Integer)>] the state of each resource
|
|
361
|
+
def resource_states(responses)
|
|
362
|
+
indexes = responses.each_with_index.to_h
|
|
363
|
+
map { |item| [item.class, item.attrs, item.hydrated?, indexes.fetch(response_of(item))] } #: Array[[singleton(Resource), Resources::attrs, bool, Integer]]
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# The resources a page is given, which must be an Array of them
|
|
367
|
+
#
|
|
368
|
+
# Items that are not, such as nil, would otherwise be taken, and raise NoMethodError when the page is read.
|
|
369
|
+
#
|
|
370
|
+
# @api private
|
|
371
|
+
# @param items [Array<Resource>] the resources
|
|
372
|
+
# @return [Array<Resource>] the resources
|
|
373
|
+
# @raise [ArgumentError] if the items are not an Array of resources
|
|
374
|
+
def resources!(items)
|
|
375
|
+
resources = Array.try_convert(items)
|
|
376
|
+
return resources if resources&.all?(Resource)
|
|
377
|
+
|
|
378
|
+
raise ArgumentError, "items must be an Array of resources, not #{items.inspect}"
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# The problems a page is given, which must be an Array of problems
|
|
382
|
+
# @api private
|
|
383
|
+
# @param problems [Object] the problems
|
|
384
|
+
# @return [Array<Problem>] the problems
|
|
385
|
+
# @raise [ArgumentError] if the problems are not an Array of problems
|
|
386
|
+
def problems!(problems)
|
|
387
|
+
array = Array.try_convert(problems)
|
|
388
|
+
return array if array&.all?(Problem)
|
|
389
|
+
|
|
390
|
+
raise ArgumentError, "problems must be an Array of problems, not #{problems.inspect}"
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
module Resources
|
|
7
|
+
# The limit of the pages a scan or a count reads, which the max_pages of each sets
|
|
8
|
+
#
|
|
9
|
+
# A scan, such as List#member?, and a count of the full archive, such as X::Post.count_all, read as many pages as
|
|
10
|
+
# the answer takes, and the API bills each one, so max_pages limits them, raising PageLimitReached rather than
|
|
11
|
+
# read past it, or answer from the pages it read.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
module PageLimit
|
|
15
|
+
extend self
|
|
16
|
+
|
|
17
|
+
# The message of the error raised for a max_pages that is neither a count of pages nor nil
|
|
18
|
+
INVALID_MAX_PAGES = "max_pages must be an Integer of at least 1, or nil for no limit, not %p"
|
|
19
|
+
# The message of the error raised for a scan or a count that read the pages its max_pages allows
|
|
20
|
+
PAGE_LIMIT_REACHED = "%<what>s read the %<max_pages>d pages max_pages allows, and the API names another"
|
|
21
|
+
private_constant :INVALID_MAX_PAGES, :PAGE_LIMIT_REACHED
|
|
22
|
+
|
|
23
|
+
# Check that a limit of pages is a count of at least one page, or nil for no limit
|
|
24
|
+
#
|
|
25
|
+
# @api private
|
|
26
|
+
# @param max_pages [Object] the limit
|
|
27
|
+
# @return [Integer, nil] the limit
|
|
28
|
+
# @raise [ArgumentError] if the limit is neither an Integer of at least 1 nor nil
|
|
29
|
+
# @example Check a limit of ten pages
|
|
30
|
+
# X::Resources::PageLimit.check!(10) # => 10
|
|
31
|
+
def check!(max_pages)
|
|
32
|
+
return max_pages if max_pages.nil? || (max_pages.instance_of?(Integer) && max_pages.positive?)
|
|
33
|
+
|
|
34
|
+
raise ArgumentError, format(INVALID_MAX_PAGES, max_pages)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Raise for a scan or a count that read the pages its limit allows
|
|
38
|
+
#
|
|
39
|
+
# It raises only when the API names a page after them, since a scan or a count that read every page is done.
|
|
40
|
+
#
|
|
41
|
+
# @api private
|
|
42
|
+
# @param what [String] what read the pages, which the error names
|
|
43
|
+
# @param read [Integer] the number of pages read
|
|
44
|
+
# @param max_pages [Integer, nil] the limit, or nil for none
|
|
45
|
+
# @param next_token [String, nil] the token of the page after the last read, or nil for none
|
|
46
|
+
# @return [void]
|
|
47
|
+
# @raise [PageLimitReached] if the pages read reach the limit, and the API names a page after them
|
|
48
|
+
# @example Stop a scan at its tenth page
|
|
49
|
+
# X::Resources::PageLimit.reached!("List#member?", read: 10, max_pages: 10, next_token: "7140w")
|
|
50
|
+
def reached!(what, read:, max_pages:, next_token:)
|
|
51
|
+
return unless read.eql?(max_pages) && next_token
|
|
52
|
+
|
|
53
|
+
raise PageLimitReached, format(PAGE_LIMIT_REACHED, what:, max_pages:)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Scan the pages of a cursor until one holds a resource
|
|
57
|
+
#
|
|
58
|
+
# It reads no more pages than a limit allows.
|
|
59
|
+
#
|
|
60
|
+
# @api private
|
|
61
|
+
# @param cursor [Cursor] the cursor
|
|
62
|
+
# @param resource [Resource] the resource to look for
|
|
63
|
+
# @param what [String] what scans, which the error names
|
|
64
|
+
# @param max_pages [Integer, nil] the most pages to read, or nil for no limit
|
|
65
|
+
# @return [Boolean] true if a page holds the resource
|
|
66
|
+
# @raise [PageLimitReached] if the pages read reach the limit without the resource, and the API names another
|
|
67
|
+
# @example Scan the members of a list for a user, ten pages at most
|
|
68
|
+
# X::Resources::PageLimit.scan(list.members.stubs, user, what: "List#member?", max_pages: 10)
|
|
69
|
+
def scan(cursor, resource, what:, max_pages:)
|
|
70
|
+
cursor.each_page.with_index(1) do |page, read|
|
|
71
|
+
return true if page.include?(resource)
|
|
72
|
+
|
|
73
|
+
reached!(what, read:, max_pages:, next_token: page.next_token)
|
|
74
|
+
end
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
private_constant :PageLimit
|
|
79
|
+
end
|
|
80
|
+
end
|