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,270 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require "x/core"
|
|
5
|
+
require_relative "batch"
|
|
6
|
+
require_relative "batch_finders"
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
require_relative "page"
|
|
9
|
+
require_relative "utils"
|
|
10
|
+
|
|
11
|
+
module X
|
|
12
|
+
module Resources
|
|
13
|
+
# The pages of a cursor, fetched when they are asked for and kept
|
|
14
|
+
# @api private
|
|
15
|
+
class Pages
|
|
16
|
+
# The message of the error raised for a page whose next token fetched an earlier page
|
|
17
|
+
REPEATED_TOKEN = "Page %<index>d of %<path>s names the next_token %<token>p, which fetched an earlier page"
|
|
18
|
+
private_constant :REPEATED_TOKEN
|
|
19
|
+
|
|
20
|
+
# Initialize the pages of a cursor
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
# @param cursor [Cursor] the cursor whose pages these are
|
|
24
|
+
# @return [Pages] the pages
|
|
25
|
+
def initialize(cursor)
|
|
26
|
+
@cursor = cursor
|
|
27
|
+
@monitor = Monitor.new
|
|
28
|
+
@pages, @prefetching, @failures = [], Set.new, {}
|
|
29
|
+
freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The page at an index, fetching the next in the background when prefetching
|
|
33
|
+
#
|
|
34
|
+
# @api private
|
|
35
|
+
# @param index [Integer] the zero-based page index
|
|
36
|
+
# @return [Page, nil] the page or nil if the collection has fewer pages
|
|
37
|
+
# @raise [TypeError] if the index is not an Integer
|
|
38
|
+
# @raise [ArgumentError] if the index is negative, since pages are read forward from the first
|
|
39
|
+
def at(index)
|
|
40
|
+
raise TypeError, "#{index.inspect} is not a page index: pages are numbered by Integers" unless index.instance_of?(Integer)
|
|
41
|
+
raise ArgumentError, "#{index} is not a page index: pages are numbered from zero" if index.negative?
|
|
42
|
+
|
|
43
|
+
current = cached(index)
|
|
44
|
+
prefetch(index + 1) if @cursor.prefetch? && current&.next_token
|
|
45
|
+
current
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The first resources, reading pages no larger than needed and keeping them
|
|
49
|
+
#
|
|
50
|
+
# The pages already fetched are read first, so a cursor that holds what is asked for pays for no request, and
|
|
51
|
+
# each page fetched asks for no more than what is asked for that the pages before it left, raised to the
|
|
52
|
+
# smallest page the endpoint accepts. The pages it fetches are kept, as every page is, so an iteration after
|
|
53
|
+
# it requests only what it left.
|
|
54
|
+
#
|
|
55
|
+
# @api private
|
|
56
|
+
# @param count [Integer] the number of resources wanted
|
|
57
|
+
# @return [Array<Resource>] the first resources, fewer if the collection holds fewer
|
|
58
|
+
# @raise [ArgumentError] if the count is negative, which Array#first raises for
|
|
59
|
+
def read(count)
|
|
60
|
+
@monitor.synchronize do
|
|
61
|
+
resources = fetched.flat_map(&:to_a)
|
|
62
|
+
while resources.size < count && (page = next_page(count - resources.size))
|
|
63
|
+
resources.concat(page.to_a)
|
|
64
|
+
end
|
|
65
|
+
resources.first(count).freeze
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# The pages fetched so far
|
|
72
|
+
#
|
|
73
|
+
# A nil marks the end of the collection, and only ever follows every page, so what is not nil is every page.
|
|
74
|
+
#
|
|
75
|
+
# @api private
|
|
76
|
+
# @return [Array<Page>] the pages
|
|
77
|
+
def fetched = @pages.compact
|
|
78
|
+
|
|
79
|
+
# Fetch and keep the page after the pages fetched so far, sized for what is wanted
|
|
80
|
+
# @api private
|
|
81
|
+
# @param wanted [Integer] the number of resources wanted from the page
|
|
82
|
+
# @return [Page, nil] the page, or nil if the collection has no more
|
|
83
|
+
def next_page(wanted)
|
|
84
|
+
index = fetched.size
|
|
85
|
+
@pages[index] ||= fetch(index, wanted)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Fetch the pages up to an index, in order, storing each in the cache
|
|
89
|
+
#
|
|
90
|
+
# The token of one page asks for the next, so the pages before an index are read first, one after another
|
|
91
|
+
# rather than one within another, since a collection of many pages would otherwise nest as deep as it is long.
|
|
92
|
+
#
|
|
93
|
+
# @api private
|
|
94
|
+
# @param index [Integer] the zero-based page index
|
|
95
|
+
# @return [Page, nil] the page or nil if the collection has fewer pages
|
|
96
|
+
def cached(index)
|
|
97
|
+
@monitor.synchronize do
|
|
98
|
+
page = nil #: Page?
|
|
99
|
+
(0..index).each do |current|
|
|
100
|
+
page = @pages[current] ||= fetch(current)
|
|
101
|
+
break if page.nil?
|
|
102
|
+
end
|
|
103
|
+
page
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Fetch a page from the API, unless a background thread failed to fetch it
|
|
108
|
+
#
|
|
109
|
+
# The error the thread failed with is raised in place of a request, once, so a page whose request failed is not
|
|
110
|
+
# requested again as soon as it is asked for, and one asked for after that error is requested again. A page
|
|
111
|
+
# fetched here, or whose error is raised here, is let go by a background thread that claimed it and has yet to
|
|
112
|
+
# fetch it, so that thread does not request it again, nor keep an error of its own for it.
|
|
113
|
+
#
|
|
114
|
+
# @api private
|
|
115
|
+
# @param index [Integer] the zero-based page index
|
|
116
|
+
# @param wanted [Integer, nil] the number of resources wanted from the page, or nil for the page size
|
|
117
|
+
# @return [Page, nil] the page or nil if the previous page was the last
|
|
118
|
+
# @raise [StandardError] the error a background thread failed to fetch the page with
|
|
119
|
+
# @raise [UnreadableResponse] if the previous page names the token of a page before it as the next
|
|
120
|
+
# @raise [InvalidAttribute] if the response holds a meta that is not an object
|
|
121
|
+
def fetch(index, wanted = nil)
|
|
122
|
+
@prefetching.delete(index)
|
|
123
|
+
@failures.delete(index)&.then { |failure| raise failure }
|
|
124
|
+
|
|
125
|
+
params = params_for(index)
|
|
126
|
+
return if params.nil?
|
|
127
|
+
|
|
128
|
+
params = sized(params, wanted) unless wanted.nil?
|
|
129
|
+
body = requester.get(Utils.path(@cursor.__send__(:path), params), **Utils::JSON_CLASSES)
|
|
130
|
+
Page.new(resources_from(body), meta: Page.__send__(:meta_of, body), problems: Problem.all_from(body))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The client that fetches the pages, as the app for a space endpoint
|
|
134
|
+
# @api private
|
|
135
|
+
# @return [Object] the client
|
|
136
|
+
def requester = @cursor.__send__(:app_only?) ? Utils.app_client(@cursor.client) : @cursor.client
|
|
137
|
+
|
|
138
|
+
# Build the resources of a page, as stubs for a cursor of identifiers
|
|
139
|
+
# @api private
|
|
140
|
+
# @param body [Hash, nil] the response body
|
|
141
|
+
# @return [Array<Resource>] the resources
|
|
142
|
+
def resources_from(body)
|
|
143
|
+
klass = @cursor.resource_class
|
|
144
|
+
params = @cursor.__send__(:params)
|
|
145
|
+
resources = klass.__send__(:collection_built_from, body, client: @cursor.client, hydrated: klass.__send__(:fully_requested_by?, params), query: params)
|
|
146
|
+
id_only? ? stubs_from(resources) : resources
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The stubs of a page, which hydrate together, a lookup's worth at a time
|
|
150
|
+
#
|
|
151
|
+
# Hydrating one stub looks up the stubs of its batch in one request, rather than every stub of a page of up
|
|
152
|
+
# to a thousand, since the API bills each resource a lookup returns. A resource without a batch lookup, such
|
|
153
|
+
# as a list, whose class BatchFinders does not extend, hydrates each stub on its own.
|
|
154
|
+
#
|
|
155
|
+
# @api private
|
|
156
|
+
# @param resources [Array<Resource>] the resources of the page
|
|
157
|
+
# @return [Array<Resource>] the stubs
|
|
158
|
+
def stubs_from(resources)
|
|
159
|
+
klass = @cursor.resource_class
|
|
160
|
+
client = @cursor.client
|
|
161
|
+
resources.each_slice(BatchFinders::MAX_BATCH_SIZE).flat_map do |slice|
|
|
162
|
+
batch = (Batch.new(klass, slice, client:) if klass.is_a?(BatchFinders))
|
|
163
|
+
slice.map { |resource| klass.__send__(:from_id_in_batch, resource, client:, batch:) }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Check whether the cursor requests nothing but identifiers
|
|
168
|
+
# @api private
|
|
169
|
+
# @return [Boolean] true if the fields parameter selects only the identifier, or the endpoint gives nothing else
|
|
170
|
+
def id_only? = @cursor.__send__(:ids_only?) || @cursor.__send__(:params)[@cursor.resource_class.__send__(:fields_key)].eql?(@cursor.resource_class.__send__(:id_key))
|
|
171
|
+
|
|
172
|
+
# Build the query parameters for a page, including the previous page token
|
|
173
|
+
#
|
|
174
|
+
# The page before this one is already fetched, since the pages are read in order.
|
|
175
|
+
#
|
|
176
|
+
# @api private
|
|
177
|
+
# @param index [Integer] the zero-based page index
|
|
178
|
+
# @return [Hash{String => Object}, nil] the parameters or nil if the previous page was the last
|
|
179
|
+
# @raise [UnreadableResponse] if the previous page names the token of a page before it as the next
|
|
180
|
+
def params_for(index)
|
|
181
|
+
return @cursor.__send__(:params) if index.zero?
|
|
182
|
+
|
|
183
|
+
token = next_token(index - 1) or return
|
|
184
|
+
@cursor.__send__(:params).merge(@cursor.__send__(:token_param) => token)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The token of the page after a page, which fetched no page before it
|
|
188
|
+
#
|
|
189
|
+
# A page that names the token of a page before it as the next would have the pages fetched again for good, and
|
|
190
|
+
# the API bill each of them, so it raises instead. The token the cursor was given, which fetched its first page,
|
|
191
|
+
# is the token of a page before each of them too.
|
|
192
|
+
#
|
|
193
|
+
# @api private
|
|
194
|
+
# @param index [Integer] the zero-based index of the page, which is fetched
|
|
195
|
+
# @return [String, nil] the token, or nil if the page is the last
|
|
196
|
+
# @raise [UnreadableResponse] if the page names the token of a page before it as the next
|
|
197
|
+
def next_token(index)
|
|
198
|
+
pages = fetched
|
|
199
|
+
token = pages.fetch(index).next_token
|
|
200
|
+
raise UnreadableResponse, format(REPEATED_TOKEN, index:, path: @cursor.__send__(:path), token:) if earlier_tokens(pages, index).include?(token)
|
|
201
|
+
|
|
202
|
+
token
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The tokens that fetched the pages up to a page
|
|
206
|
+
#
|
|
207
|
+
# They are the token the cursor was given, if any, then the next token of each page before it.
|
|
208
|
+
#
|
|
209
|
+
# @api private
|
|
210
|
+
# @param pages [Array<Page>] the pages fetched
|
|
211
|
+
# @param index [Integer] the zero-based index of the page
|
|
212
|
+
# @return [Array<String>] the tokens
|
|
213
|
+
def earlier_tokens(pages, index)
|
|
214
|
+
[@cursor.__send__(:params)[@cursor.__send__(:token_param)], *pages.take(index).map(&:next_token)].compact
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The query parameters of a page, asking for no more than the resources wanted
|
|
218
|
+
#
|
|
219
|
+
# A cursor over an endpoint without a page size asks for the page as it is. The page size is raised to the
|
|
220
|
+
# smallest the endpoint accepts, but never past the max_results the cursor was given, so a max_results below
|
|
221
|
+
# that smallest page is sent as it was given, for the API to refuse, as an iteration sends it.
|
|
222
|
+
#
|
|
223
|
+
# @api private
|
|
224
|
+
# @param params [Hash{String => Object}] the query parameters of the page
|
|
225
|
+
# @param wanted [Integer] the number of resources wanted
|
|
226
|
+
# @return [Hash{String => Object}] the parameters, with the page size wanted, within the endpoint's limits
|
|
227
|
+
def sized(params, wanted)
|
|
228
|
+
maximum = params["max_results"]
|
|
229
|
+
return params if maximum.nil?
|
|
230
|
+
|
|
231
|
+
params.merge("max_results" => [wanted, @cursor.__send__(:min_results)].max.clamp(..Integer(maximum)))
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Fetch a page in a background thread; errors resurface when the page is requested
|
|
235
|
+
#
|
|
236
|
+
# A page already fetched, or being fetched by another thread, starts no thread, so reading the pages a cursor
|
|
237
|
+
# holds, or reading one page again, starts none. An error the thread fails with is kept, holding the lock of the
|
|
238
|
+
# pages, so no caller requests the page between the failure and its keeping, and raised to the caller that asks
|
|
239
|
+
# for the page, in place of a request for it; until then, a thread started for the page keeps it again rather
|
|
240
|
+
# than request the page. A thread whose page a caller fetched before the thread took the lock requests nothing.
|
|
241
|
+
# The pages before the page are fetched by the time it is claimed, so the thread reaches fetch for it, which
|
|
242
|
+
# lets the page go, unless a caller let it go first.
|
|
243
|
+
#
|
|
244
|
+
# @api private
|
|
245
|
+
# @param index [Integer] the zero-based page index
|
|
246
|
+
# @return [Thread, nil] the background thread, or nil if the page needs none
|
|
247
|
+
def prefetch(index)
|
|
248
|
+
return unless claim(index)
|
|
249
|
+
|
|
250
|
+
Thread.new do
|
|
251
|
+
@monitor.synchronize do
|
|
252
|
+
cached(index) if @prefetching.include?(index)
|
|
253
|
+
rescue => e
|
|
254
|
+
@failures[index] = e
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Claim a page for a background thread to fetch
|
|
260
|
+
# @api private
|
|
261
|
+
# @param index [Integer] the zero-based page index
|
|
262
|
+
# @return [Boolean] true if the page was claimed, which no other thread fetches until the thread that claimed it
|
|
263
|
+
# is done
|
|
264
|
+
def claim(index)
|
|
265
|
+
@monitor.synchronize { !@pages.at(index) && !@prefetching.add?(index).nil? }
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
private_constant :Pages
|
|
269
|
+
end
|
|
270
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X
|
|
4
|
+
module Resources
|
|
5
|
+
# Runs blocks across a bounded pool of threads
|
|
6
|
+
# @api private
|
|
7
|
+
module Parallel
|
|
8
|
+
extend self
|
|
9
|
+
|
|
10
|
+
# Map over items concurrently while preserving order
|
|
11
|
+
#
|
|
12
|
+
# Every item is given its place in the results before any thread starts, so that a worker never resizes the
|
|
13
|
+
# array it writes into, which keeps the writes of the workers safe on a Ruby that runs them in parallel.
|
|
14
|
+
#
|
|
15
|
+
# A block that raises stops the items not yet begun, since each is a request the API bills, and once the
|
|
16
|
+
# items already begun have finished, the first error raised is raised again. An interruption of the wait,
|
|
17
|
+
# such as a timeout, a shutdown, or a signal, stops them the same way, rather than leave the threads to
|
|
18
|
+
# spend what the caller is no longer waiting for.
|
|
19
|
+
#
|
|
20
|
+
# @api private
|
|
21
|
+
# @param items [Enumerable] the items to map
|
|
22
|
+
# @param concurrency [Integer] the maximum number of concurrent threads, which callers check is at least one
|
|
23
|
+
# @yield [Object] each item
|
|
24
|
+
# @return [Array] the results in the same order as the items
|
|
25
|
+
# @raise [StandardError] the first error raised by any block
|
|
26
|
+
def map(items, concurrency:, &block)
|
|
27
|
+
items = items.to_a
|
|
28
|
+
results = Array.new(items.size) #: Array[untyped]
|
|
29
|
+
queue = Queue.new
|
|
30
|
+
items.each_index { |index| queue << index }
|
|
31
|
+
queue.close
|
|
32
|
+
errors = Queue.new
|
|
33
|
+
drain(queue, [concurrency, items.size].min) { worker(queue, errors, items, results, &block) }
|
|
34
|
+
raise errors.deq unless errors.empty?
|
|
35
|
+
|
|
36
|
+
results
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Start the workers and wait for them, emptying the queue however the wait ends
|
|
42
|
+
#
|
|
43
|
+
# The workers are started inside it, so that an exception raised in the caller as one is started, such as an
|
|
44
|
+
# interrupt, empties the queue too, rather than leave the workers started to work through it once the caller
|
|
45
|
+
# has gone.
|
|
46
|
+
#
|
|
47
|
+
# @api private
|
|
48
|
+
# @param queue [Queue] the queue of item indexes
|
|
49
|
+
# @param count [Integer] the number of workers to start
|
|
50
|
+
# @yieldreturn [Thread] a worker it started
|
|
51
|
+
# @return [void]
|
|
52
|
+
def drain(queue, count)
|
|
53
|
+
threads = [] #: Array[Thread]
|
|
54
|
+
count.times { threads << yield }
|
|
55
|
+
threads.each(&:join)
|
|
56
|
+
ensure
|
|
57
|
+
queue.clear
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Start a worker thread that drains the queue, emptying it if the block raises
|
|
61
|
+
#
|
|
62
|
+
# @api private
|
|
63
|
+
# @param queue [Queue] the queue of item indexes
|
|
64
|
+
# @param errors [Queue] the errors the block raised, in the order it raised them
|
|
65
|
+
# @param items [Array] the items to map
|
|
66
|
+
# @param results [Array] the results to fill
|
|
67
|
+
# @yield [Object] each item
|
|
68
|
+
# @return [Thread] the worker thread
|
|
69
|
+
def worker(queue, errors, items, results, &block)
|
|
70
|
+
Thread.new do
|
|
71
|
+
while (index = queue.deq)
|
|
72
|
+
results[index] = block.call(items.fetch(index))
|
|
73
|
+
end
|
|
74
|
+
rescue => e
|
|
75
|
+
errors << e
|
|
76
|
+
queue.clear
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
private_constant :Parallel
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "serialization"
|
|
4
|
+
require_relative "shape"
|
|
5
|
+
require_relative "utils"
|
|
6
|
+
require_relative "value_equality"
|
|
7
|
+
require_relative "value_marshalling"
|
|
8
|
+
|
|
9
|
+
module X
|
|
10
|
+
module Resources
|
|
11
|
+
# A topic trending for the authenticated user, as the trends X picks for them report it
|
|
12
|
+
#
|
|
13
|
+
# A personalized trend has no identifier, and the API offers no lookup of one, so the trends are read all at once,
|
|
14
|
+
# for the user the client authenticates as. They are described as they are shown: the number of posts, and how long
|
|
15
|
+
# the topic has trended, are text such as "12.3K posts", rather than numbers.
|
|
16
|
+
#
|
|
17
|
+
# @api public
|
|
18
|
+
class ::X::PersonalizedTrend
|
|
19
|
+
include Serialization
|
|
20
|
+
include ValueEquality
|
|
21
|
+
include ValueMarshalling
|
|
22
|
+
|
|
23
|
+
# The mixins of the class by their full names, which YARD needs to resolve them in a class opened under X
|
|
24
|
+
#
|
|
25
|
+
# @!parse
|
|
26
|
+
# include X::Resources::Serialization
|
|
27
|
+
# include X::Resources::ValueEquality
|
|
28
|
+
# include X::Resources::ValueMarshalling
|
|
29
|
+
|
|
30
|
+
# The endpoint that reports the trends of the authenticated user
|
|
31
|
+
# @api private
|
|
32
|
+
ENDPOINT = "users/personalized_trends"
|
|
33
|
+
private_constant :ENDPOINT
|
|
34
|
+
# Every personalized trend field
|
|
35
|
+
#
|
|
36
|
+
# A minor release may add to it the fields the API adds, so that the trends ask for them too.
|
|
37
|
+
FIELDS = %w[category post_count trend_name trending_since].freeze
|
|
38
|
+
|
|
39
|
+
# The raw attributes of the trend
|
|
40
|
+
# @api public
|
|
41
|
+
# @return [Hash{String => Object}] the attributes
|
|
42
|
+
# @example Get the raw attributes
|
|
43
|
+
# trend.attrs # => {"trend_name" => "#ruby", "category" => "Technology", ...}
|
|
44
|
+
attr_reader :attrs
|
|
45
|
+
|
|
46
|
+
# The topics trending for the authenticated user
|
|
47
|
+
#
|
|
48
|
+
# The endpoint names no user, so these are always the trends of the user the client authenticates as, and it
|
|
49
|
+
# takes a user's authentication alone, so a client that authenticates as the app is refused.
|
|
50
|
+
#
|
|
51
|
+
# @api public
|
|
52
|
+
# @param client [Object] the client used to make the request
|
|
53
|
+
# @param params [Hash] query parameters merged over the default parameters
|
|
54
|
+
# @return [Array<PersonalizedTrend>] the trends, frozen
|
|
55
|
+
# @raise [InvalidAttribute] if the response holds the trends as something other than a list of objects
|
|
56
|
+
# @example Print the topics trending for the authenticated user
|
|
57
|
+
# X::PersonalizedTrend.all(client: client).each { |trend| puts "#{trend.name}: #{trend.post_count_text}" }
|
|
58
|
+
def self.all(client:, **params)
|
|
59
|
+
body = client.get(Utils.path(ENDPOINT, {"personalized_trend.fields" => FIELDS}.merge(params)), **Utils::JSON_CLASSES)
|
|
60
|
+
Shape.objects("#{self}.all", body.to_h["data"]).map { |attrs| new(attrs) }.freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Initialize a trend from the attributes the API reported
|
|
64
|
+
#
|
|
65
|
+
# @api public
|
|
66
|
+
# @param attrs [Hash{String, Symbol => Object}] the attributes
|
|
67
|
+
# @return [PersonalizedTrend] a new trend
|
|
68
|
+
# @raise [ArgumentError] if the attributes are not a Hash
|
|
69
|
+
# @example Build a trend
|
|
70
|
+
# X::PersonalizedTrend.new({"trend_name" => "#ruby", "post_count" => "12.3K posts"})
|
|
71
|
+
def initialize(attrs)
|
|
72
|
+
@attrs = Utils.deep_freeze(Utils.attributes!(attrs))
|
|
73
|
+
freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The name of the trend, such as a hashtag or a phrase
|
|
77
|
+
#
|
|
78
|
+
# @api public
|
|
79
|
+
# @return [String, nil] the name
|
|
80
|
+
# @example Get the name
|
|
81
|
+
# trend.name # => "#ruby"
|
|
82
|
+
def name = attrs["trend_name"]
|
|
83
|
+
|
|
84
|
+
# The category of the trend
|
|
85
|
+
#
|
|
86
|
+
# @api public
|
|
87
|
+
# @return [String, nil] the category
|
|
88
|
+
# @example Get the category
|
|
89
|
+
# trend.category # => "Technology"
|
|
90
|
+
def category = attrs["category"]
|
|
91
|
+
|
|
92
|
+
# The number of posts about the trend, as the text X shows it
|
|
93
|
+
#
|
|
94
|
+
# It is named for the text it is, apart from the post_count of X::Trend, which is an Integer, so that code that
|
|
95
|
+
# reads both trends never takes one for the other.
|
|
96
|
+
#
|
|
97
|
+
# @api public
|
|
98
|
+
# @return [String, nil] the number of posts, as text
|
|
99
|
+
# @example Get the number of posts
|
|
100
|
+
# trend.post_count_text # => "12.3K posts"
|
|
101
|
+
def post_count_text = attrs["post_count"]
|
|
102
|
+
|
|
103
|
+
# How long the topic has trended, as the text X shows it
|
|
104
|
+
#
|
|
105
|
+
# It is named for the text it is, as post_count_text is, so that it is not taken for a Time, which every other
|
|
106
|
+
# reader of when something happened answers.
|
|
107
|
+
#
|
|
108
|
+
# @api public
|
|
109
|
+
# @return [String, nil] how long the topic has trended, as text
|
|
110
|
+
# @example Get how long the topic has trended
|
|
111
|
+
# trend.trending_since_text # => "Trending now"
|
|
112
|
+
def trending_since_text = attrs["trending_since"]
|
|
113
|
+
|
|
114
|
+
# Deconstruct the trend into what its readers read, so it matches a hash pattern
|
|
115
|
+
#
|
|
116
|
+
# @api public
|
|
117
|
+
# @param keys [Array<Symbol>, nil] the keys the pattern asks for, or nil for every reader
|
|
118
|
+
# @return [Hash{Symbol => String, nil}] what the readers read
|
|
119
|
+
# @example Keep the topics of the technology category
|
|
120
|
+
# X::PersonalizedTrend.all(client: client).select { |trend| trend in {category: "Technology"} }
|
|
121
|
+
def deconstruct_keys(keys) = Utils.deconstruct(self, keys, %i[name category post_count_text trending_since_text])
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# A place tagged in a post
|
|
7
|
+
#
|
|
8
|
+
# The API offers no lookup of places, so a place is read from the response of the post that expanded it, and the class
|
|
9
|
+
# answers no finder. from_id builds one from its identifier, which a place the response did not expand is built as
|
|
10
|
+
# too, and which compares equal to the place it identifies, but hydrate and refresh raise UnsupportedOperation for one
|
|
11
|
+
# that is not hydrated, since there is nothing to look it up with.
|
|
12
|
+
#
|
|
13
|
+
# @api public
|
|
14
|
+
class Place < Resource
|
|
15
|
+
# Every public place field
|
|
16
|
+
#
|
|
17
|
+
# A minor release may add to it the fields the API adds, so that a lookup asks for them too; see
|
|
18
|
+
# {Resource#hydrated?} for what that means for a resource looked up with a list of fields of its own.
|
|
19
|
+
FIELDS = %w[contained_within country country_code full_name geo id name place_type].freeze
|
|
20
|
+
|
|
21
|
+
# The type of the identifier, which is not a number
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
# @return [Symbol] raw
|
|
25
|
+
# @example Get the identifier type
|
|
26
|
+
# X::Place.__send__(:id_type) # => :raw
|
|
27
|
+
def self.id_type = :raw
|
|
28
|
+
|
|
29
|
+
# The default query parameters, which request every field
|
|
30
|
+
#
|
|
31
|
+
# The API offers no lookup of places, so they are requested by the posts that expand them, which ask for these
|
|
32
|
+
# fields, and a place a response included with all of them is hydrated.
|
|
33
|
+
#
|
|
34
|
+
# @api public
|
|
35
|
+
# @return [Hash{String => Array<String>}] the default query parameters
|
|
36
|
+
# @example Get the default parameters
|
|
37
|
+
# X::Place.default_params # => {"place.fields" => [...]}
|
|
38
|
+
def self.default_params = {"place.fields" => FIELDS}
|
|
39
|
+
|
|
40
|
+
# The key under which places appear in the includes of a response
|
|
41
|
+
#
|
|
42
|
+
# @api private
|
|
43
|
+
# @return [String] the includes key
|
|
44
|
+
# @example Get the includes key
|
|
45
|
+
# X::Place.__send__(:includes_key) # => "places"
|
|
46
|
+
def self.includes_key
|
|
47
|
+
"places"
|
|
48
|
+
end
|
|
49
|
+
private_class_method :id_type, :includes_key
|
|
50
|
+
|
|
51
|
+
# @!attribute [r] name
|
|
52
|
+
# The short name
|
|
53
|
+
# @api public
|
|
54
|
+
# @return [String, nil] the short name
|
|
55
|
+
# @example Get the name
|
|
56
|
+
# place.name
|
|
57
|
+
attribute :name
|
|
58
|
+
|
|
59
|
+
# @!attribute [r] full_name
|
|
60
|
+
# The full name
|
|
61
|
+
# @api public
|
|
62
|
+
# @return [String, nil] the full name
|
|
63
|
+
# @example Get the full name
|
|
64
|
+
# place.full_name
|
|
65
|
+
attribute :full_name
|
|
66
|
+
|
|
67
|
+
# @!attribute [r] country
|
|
68
|
+
# The country name
|
|
69
|
+
# @api public
|
|
70
|
+
# @return [String, nil] the country name
|
|
71
|
+
# @example Get the country
|
|
72
|
+
# place.country
|
|
73
|
+
attribute :country
|
|
74
|
+
|
|
75
|
+
# @!attribute [r] country_code
|
|
76
|
+
# The ISO 3166-1 alpha-2 country code
|
|
77
|
+
# @api public
|
|
78
|
+
# @return [String, nil] the country code
|
|
79
|
+
# @example Get the country code
|
|
80
|
+
# place.country_code
|
|
81
|
+
attribute :country_code
|
|
82
|
+
|
|
83
|
+
# @!attribute [r] place_type
|
|
84
|
+
# The place type, such as city or poi
|
|
85
|
+
# @api public
|
|
86
|
+
# @return [String, nil] the place type
|
|
87
|
+
# @example Get the place type
|
|
88
|
+
# place.place_type
|
|
89
|
+
attribute :place_type
|
|
90
|
+
|
|
91
|
+
# @!attribute [r] contained_within
|
|
92
|
+
# The identifiers of the places containing this place
|
|
93
|
+
# @api public
|
|
94
|
+
# @return [Array<String>] the containing place identifiers, empty if there are none
|
|
95
|
+
# @example Get the containing places
|
|
96
|
+
# place.contained_within
|
|
97
|
+
attribute :contained_within, :list
|
|
98
|
+
|
|
99
|
+
# @!attribute [r] geo
|
|
100
|
+
# The GeoJSON bounding box
|
|
101
|
+
# @api public
|
|
102
|
+
# @return [Hash, nil] the GeoJSON
|
|
103
|
+
# @example Get the GeoJSON
|
|
104
|
+
# place.geo
|
|
105
|
+
attribute :geo, :object
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# A poll attached to a post
|
|
7
|
+
#
|
|
8
|
+
# The API offers no lookup of polls, so a poll is read from the response of the post that expanded it, and the class
|
|
9
|
+
# answers no finder. from_id builds one from its identifier, which a poll the response did not expand is built as too,
|
|
10
|
+
# and which compares equal to the poll it identifies, but hydrate and refresh raise UnsupportedOperation for one that
|
|
11
|
+
# is not hydrated, since there is nothing to look it up with.
|
|
12
|
+
#
|
|
13
|
+
# @api public
|
|
14
|
+
class Poll < Resource
|
|
15
|
+
# Every public poll field
|
|
16
|
+
#
|
|
17
|
+
# A minor release may add to it the fields the API adds, so that a lookup asks for them too; see
|
|
18
|
+
# {Resource#hydrated?} for what that means for a resource looked up with a list of fields of its own.
|
|
19
|
+
FIELDS = %w[duration_minutes end_datetime id options voting_status].freeze
|
|
20
|
+
|
|
21
|
+
# The default query parameters, which request every field
|
|
22
|
+
#
|
|
23
|
+
# The API offers no lookup of polls, so they are requested by the posts that expand them, which ask for these
|
|
24
|
+
# fields, and a poll a response included with all of them is hydrated.
|
|
25
|
+
#
|
|
26
|
+
# @api public
|
|
27
|
+
# @return [Hash{String => Array<String>}] the default query parameters
|
|
28
|
+
# @example Get the default parameters
|
|
29
|
+
# X::Poll.default_params # => {"poll.fields" => [...]}
|
|
30
|
+
def self.default_params = {"poll.fields" => FIELDS}
|
|
31
|
+
|
|
32
|
+
# The key under which polls appear in the includes of a response
|
|
33
|
+
#
|
|
34
|
+
# @api private
|
|
35
|
+
# @return [String] the includes key
|
|
36
|
+
# @example Get the includes key
|
|
37
|
+
# X::Poll.__send__(:includes_key) # => "polls"
|
|
38
|
+
def self.includes_key
|
|
39
|
+
"polls"
|
|
40
|
+
end
|
|
41
|
+
private_class_method :includes_key
|
|
42
|
+
|
|
43
|
+
# @!attribute [r] options
|
|
44
|
+
# The poll options with their positions, labels, and vote counts
|
|
45
|
+
# @api public
|
|
46
|
+
# @return [Array<Hash>] the options, empty if there are none
|
|
47
|
+
# @example Get the options
|
|
48
|
+
# poll.options
|
|
49
|
+
attribute :options, :objects
|
|
50
|
+
|
|
51
|
+
# @!attribute [r] duration_minutes
|
|
52
|
+
# The duration of the poll in minutes
|
|
53
|
+
# @api public
|
|
54
|
+
# @return [Integer, nil] the duration in minutes
|
|
55
|
+
# @example Get the duration
|
|
56
|
+
# poll.duration_minutes
|
|
57
|
+
attribute :duration_minutes, :integer
|
|
58
|
+
|
|
59
|
+
# @!attribute [r] end_datetime
|
|
60
|
+
# The time when the poll ends
|
|
61
|
+
# @api public
|
|
62
|
+
# @return [Time, nil] the end time
|
|
63
|
+
# @example Get the end time
|
|
64
|
+
# poll.end_datetime
|
|
65
|
+
attribute :end_datetime, :time
|
|
66
|
+
|
|
67
|
+
# @!attribute [r] voting_status
|
|
68
|
+
# The voting status: open or closed
|
|
69
|
+
# @api public
|
|
70
|
+
# @return [String, nil] the voting status
|
|
71
|
+
# @example Get the voting status
|
|
72
|
+
# poll.voting_status
|
|
73
|
+
attribute :voting_status
|
|
74
|
+
end
|
|
75
|
+
end
|