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.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +254 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +148 -0
  6. data/lib/x/resources/abstract_class.rb +29 -0
  7. data/lib/x/resources/actions/direct_messages.rb +99 -0
  8. data/lib/x/resources/actions/engagement.rb +95 -0
  9. data/lib/x/resources/actions/lists.rb +126 -0
  10. data/lib/x/resources/actions/posts.rb +77 -0
  11. data/lib/x/resources/actions/relationships.rb +91 -0
  12. data/lib/x/resources/actions.rb +22 -0
  13. data/lib/x/resources/api.rb +40 -0
  14. data/lib/x/resources/attributes.rb +203 -0
  15. data/lib/x/resources/batch.rb +43 -0
  16. data/lib/x/resources/batch_finders.rb +185 -0
  17. data/lib/x/resources/bookmark_folder.rb +23 -0
  18. data/lib/x/resources/community.rb +137 -0
  19. data/lib/x/resources/cursor.rb +493 -0
  20. data/lib/x/resources/direct_message.rb +325 -0
  21. data/lib/x/resources/direct_message_conversations.rb +147 -0
  22. data/lib/x/resources/errors.rb +104 -0
  23. data/lib/x/resources/finders.rb +255 -0
  24. data/lib/x/resources/identity.rb +65 -0
  25. data/lib/x/resources/includes.rb +216 -0
  26. data/lib/x/resources/list.rb +336 -0
  27. data/lib/x/resources/lookups/communities.rb +56 -0
  28. data/lib/x/resources/lookups/direct_messages.rb +86 -0
  29. data/lib/x/resources/lookups/lists.rb +44 -0
  30. data/lib/x/resources/lookups/media.rb +72 -0
  31. data/lib/x/resources/lookups/posts.rb +198 -0
  32. data/lib/x/resources/lookups/spaces.rb +87 -0
  33. data/lib/x/resources/lookups/trends.rb +38 -0
  34. data/lib/x/resources/lookups/users.rb +221 -0
  35. data/lib/x/resources/lookups.rb +25 -0
  36. data/lib/x/resources/marshalling.rb +93 -0
  37. data/lib/x/resources/matching_rule.rb +107 -0
  38. data/lib/x/resources/media.rb +278 -0
  39. data/lib/x/resources/media_ids.rb +74 -0
  40. data/lib/x/resources/memo.rb +54 -0
  41. data/lib/x/resources/page.rb +394 -0
  42. data/lib/x/resources/page_limit.rb +80 -0
  43. data/lib/x/resources/pages.rb +270 -0
  44. data/lib/x/resources/parallel.rb +82 -0
  45. data/lib/x/resources/personalized_trend.rb +124 -0
  46. data/lib/x/resources/place.rb +107 -0
  47. data/lib/x/resources/poll.rb +75 -0
  48. data/lib/x/resources/post.rb +615 -0
  49. data/lib/x/resources/post_collections.rb +67 -0
  50. data/lib/x/resources/post_counts.rb +215 -0
  51. data/lib/x/resources/post_search.rb +86 -0
  52. data/lib/x/resources/post_usage.rb +203 -0
  53. data/lib/x/resources/post_writes.rb +140 -0
  54. data/lib/x/resources/published_count.rb +31 -0
  55. data/lib/x/resources/references.rb +121 -0
  56. data/lib/x/resources/relation_writes.rb +54 -0
  57. data/lib/x/resources/relationships.rb +77 -0
  58. data/lib/x/resources/resource.rb +535 -0
  59. data/lib/x/resources/serialization.rb +58 -0
  60. data/lib/x/resources/shape.rb +167 -0
  61. data/lib/x/resources/space.rb +332 -0
  62. data/lib/x/resources/topic.rb +59 -0
  63. data/lib/x/resources/trend.rb +130 -0
  64. data/lib/x/resources/user.rb +502 -0
  65. data/lib/x/resources/user_collections.rb +213 -0
  66. data/lib/x/resources/user_finders.rb +282 -0
  67. data/lib/x/resources/utils.rb +358 -0
  68. data/lib/x/resources/value_equality.rb +38 -0
  69. data/lib/x/resources/value_marshalling.rb +89 -0
  70. data/lib/x/resources/version.rb +25 -0
  71. data/lib/x/resources.rb +22 -0
  72. data/sig/manifest.yaml +7 -0
  73. data/sig/x-resources.rbs +813 -0
  74. 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