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,358 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module X
|
|
8
|
+
module Resources
|
|
9
|
+
# Helpers shared across the object layer
|
|
10
|
+
# @api private
|
|
11
|
+
module Utils
|
|
12
|
+
# Parsing options that make a client return plain hashes and arrays, whatever its defaults
|
|
13
|
+
JSON_CLASSES = {array_class: Array, object_class: Hash}.freeze
|
|
14
|
+
|
|
15
|
+
# The pattern of an identifier that is a number, which most resources are identified by
|
|
16
|
+
NUMERIC_ID = /\A\d+\z/
|
|
17
|
+
|
|
18
|
+
# The pattern of the identifiers of each type, as the id_type of a resource class names it: a number; the word
|
|
19
|
+
# characters the identifier of a space or a place is written with; and a media key, which is the number of the
|
|
20
|
+
# type of media and the numeric identifier of the media, joined with an underscore
|
|
21
|
+
ID_PATTERNS = {integer: NUMERIC_ID, raw: /\A\w+\z/, media_key: /\A\d+_\d+\z/}.freeze
|
|
22
|
+
|
|
23
|
+
# What an identifier of each type is, which the error raised for one that is not names
|
|
24
|
+
ID_DESCRIPTIONS = {integer: "an Integer, or a String of digits", raw: "or a String of word characters", media_key: "what an upload returned, or a media key, such as \"3_1880028106020515840\""}.freeze
|
|
25
|
+
|
|
26
|
+
# The pattern of a username: one to fifteen word characters, which the at sign a handle is often written with
|
|
27
|
+
# may precede
|
|
28
|
+
USERNAME = /\A@?\w{1,15}\z/
|
|
29
|
+
private_constant :ID_PATTERNS, :ID_DESCRIPTIONS, :USERNAME
|
|
30
|
+
|
|
31
|
+
# The message of the error raised for a resource of another class than the one an identifier was expected of
|
|
32
|
+
FOREIGN_RESOURCE = "%<given>s %<id>s is not %<expected>s: pass %<expected>s or its identifier"
|
|
33
|
+
# The key a client keeps the identifier of the authenticated user under, named for this gem
|
|
34
|
+
CURRENT_USER_ID = :x_resources_current_user_id
|
|
35
|
+
private_constant :FOREIGN_RESOURCE, :CURRENT_USER_ID
|
|
36
|
+
|
|
37
|
+
extend self
|
|
38
|
+
|
|
39
|
+
# Return a deep-frozen copy of a value with string keys
|
|
40
|
+
#
|
|
41
|
+
# @api private
|
|
42
|
+
# @param value [Object] the value to copy and freeze
|
|
43
|
+
# @return [Object] the frozen copy
|
|
44
|
+
def deep_freeze(value)
|
|
45
|
+
case value
|
|
46
|
+
when Hash then value.transform_keys(&:to_s).transform_values { |element| deep_freeze(element) }.freeze
|
|
47
|
+
when Array then value.map { |element| deep_freeze(element) }.freeze
|
|
48
|
+
when String then value.dup.freeze
|
|
49
|
+
else value
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The number of resources a count asks for, converted as Array#first converts it
|
|
54
|
+
#
|
|
55
|
+
# @api private
|
|
56
|
+
# @param count [Object] the count
|
|
57
|
+
# @return [Integer] the count, as an Integer
|
|
58
|
+
# @raise [TypeError] if the count does not convert to an Integer
|
|
59
|
+
# @example Read a count given as a Float
|
|
60
|
+
# X::Resources::Utils.count!(2.5) # => 2
|
|
61
|
+
def count!(count) = Integer.try_convert(count) || raise(TypeError, "no implicit conversion of #{count.class} into Integer")
|
|
62
|
+
|
|
63
|
+
# The attributes a public constructor is given, which must be a Hash
|
|
64
|
+
#
|
|
65
|
+
# Attributes that are not one, such as nil, would otherwise be taken, and raise NoMethodError from a reader, far
|
|
66
|
+
# from where they were given.
|
|
67
|
+
#
|
|
68
|
+
# @api private
|
|
69
|
+
# @param attrs [Hash] the attributes
|
|
70
|
+
# @return [Hash] the attributes
|
|
71
|
+
# @raise [ArgumentError] if the attributes are not a Hash
|
|
72
|
+
# @example Refuse attributes that are not a Hash
|
|
73
|
+
# X::Resources::Utils.attributes!(nil) # raises ArgumentError
|
|
74
|
+
def attributes!(attrs) = Hash.try_convert(attrs) || raise(ArgumentError, "attrs must be a Hash, not #{attrs.inspect}")
|
|
75
|
+
|
|
76
|
+
# Build an endpoint path with an encoded query string
|
|
77
|
+
#
|
|
78
|
+
# @api private
|
|
79
|
+
# @param base [String] the endpoint path without a query string
|
|
80
|
+
# @param params [Hash] the query parameters
|
|
81
|
+
# @return [String] the endpoint path with a query string
|
|
82
|
+
def path(base, params)
|
|
83
|
+
query = query(params)
|
|
84
|
+
return base if query.empty?
|
|
85
|
+
|
|
86
|
+
"#{base}?#{URI.encode_www_form(query)}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Normalize query parameters into string keys and comma-separated values
|
|
90
|
+
#
|
|
91
|
+
# @api private
|
|
92
|
+
# @param params [Hash] the query parameters
|
|
93
|
+
# @return [Hash{String => String, Integer}] the normalized parameters
|
|
94
|
+
def query(params)
|
|
95
|
+
params.transform_keys(&:to_s).compact.transform_values { |value| query_value(value) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Normalize the fields of a request body into Symbol keys
|
|
99
|
+
#
|
|
100
|
+
# A caller may name a field by a String or by a Symbol, so each is read by its Symbol, by which a field the
|
|
101
|
+
# object layer sets, such as attachments or reply, is checked and merged, and a body never names a field twice.
|
|
102
|
+
#
|
|
103
|
+
# @api private
|
|
104
|
+
# @param fields [Hash, nil] the fields
|
|
105
|
+
# @return [Hash{Symbol => Object}] the fields, keyed by Symbol
|
|
106
|
+
def fields(fields) = fields.to_h.transform_keys(&:to_sym)
|
|
107
|
+
|
|
108
|
+
# Normalize a query parameter value
|
|
109
|
+
#
|
|
110
|
+
# An Array is joined with commas, and a Time is given in UTC in the ISO 8601 form the API takes.
|
|
111
|
+
#
|
|
112
|
+
# @api private
|
|
113
|
+
# @param value [Object] the value
|
|
114
|
+
# @return [Object] the normalized value
|
|
115
|
+
def query_value(value)
|
|
116
|
+
case value
|
|
117
|
+
when Array then value.join(",")
|
|
118
|
+
when Time then value.getutc.iso8601
|
|
119
|
+
else value
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Merge query parameters over defaults, dropping parameters set to nil
|
|
124
|
+
#
|
|
125
|
+
# @api private
|
|
126
|
+
# @param defaults [Hash] the default query parameters
|
|
127
|
+
# @param params [Hash] the query parameters to merge over the defaults
|
|
128
|
+
# @return [Hash{String => String, Integer}] the normalized parameters
|
|
129
|
+
def merge_params(defaults, params)
|
|
130
|
+
query(defaults.merge(params))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Extract the identifier of a resource of a class from the resource or a raw value
|
|
134
|
+
#
|
|
135
|
+
# The identifiers of most resources are numbers, so a value that is not, such as a username, raises rather than
|
|
136
|
+
# reach the API as an identifier it cannot be. The identifiers that are not numbers, such as those of spaces, are
|
|
137
|
+
# word characters, so anything else raises rather than reach the API as part of a path, and a media key is two
|
|
138
|
+
# numbers joined with an underscore, so the numeric identifier of media raises rather than look up nothing. A
|
|
139
|
+
# resource of another class raises too, since its identifier is one of another kind of resource, which the API
|
|
140
|
+
# would read as the identifier of a resource of this class, such as a list followed as though it were a user.
|
|
141
|
+
#
|
|
142
|
+
# @api private
|
|
143
|
+
# @param value [Resource, String, Integer] a resource or an identifier
|
|
144
|
+
# @param klass [Class] the resource class the identifier is of
|
|
145
|
+
# @return [String] the identifier
|
|
146
|
+
# @raise [ArgumentError] if the value is a resource of another class, is neither a resource nor an identifier,
|
|
147
|
+
# or the identifier is not a number, or is not word characters for a resource whose identifiers are not
|
|
148
|
+
# numbers, such as a space, or is not a media key for media
|
|
149
|
+
def id_of(value, klass)
|
|
150
|
+
id = id_from(value, klass)
|
|
151
|
+
return id if id.match?(ID_PATTERNS.fetch(klass.__send__(:id_type)))
|
|
152
|
+
|
|
153
|
+
not_an_identifier(value, klass)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The identifier a value carries, read from a resource or taken as it is
|
|
157
|
+
#
|
|
158
|
+
# The resource this builds checks the identifier when it is made, so this only reads one, and refuses a
|
|
159
|
+
# resource of another class, whose identifier is not one of this class. Anything else that answers id, such as a
|
|
160
|
+
# record of an application's database, is refused too, rather than send an identifier of its own to the API as
|
|
161
|
+
# one of X.
|
|
162
|
+
#
|
|
163
|
+
# @api private
|
|
164
|
+
# @param value [Resource, String, Integer] a resource or an identifier
|
|
165
|
+
# @param klass [Class] the resource class the identifier is of
|
|
166
|
+
# @return [String] the identifier
|
|
167
|
+
# @raise [ArgumentError] if the value is a resource of another class, or is neither a resource nor an identifier
|
|
168
|
+
def id_from(value, klass)
|
|
169
|
+
return value.id.to_s if value.is_a?(klass)
|
|
170
|
+
return value.to_s if value.is_a?(String) || value.instance_of?(Integer)
|
|
171
|
+
raise ArgumentError, format(FOREIGN_RESOURCE, given: value.class, id: value.id, expected: klass) if value.is_a?(Resource)
|
|
172
|
+
|
|
173
|
+
not_an_identifier(value, klass)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Refuse a value that is not an identifier of a resource of a class
|
|
177
|
+
#
|
|
178
|
+
# @api private
|
|
179
|
+
# @param value [Object] the value
|
|
180
|
+
# @param klass [Class] the resource class the identifier is of
|
|
181
|
+
# @return [void]
|
|
182
|
+
# @raise [ArgumentError] always
|
|
183
|
+
def not_an_identifier(value, klass)
|
|
184
|
+
raise ArgumentError, "#{value.inspect} is not an identifier: pass #{klass}, #{ID_DESCRIPTIONS.fetch(klass.__send__(:id_type))}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Normalize a username, dropping the at sign a handle is often written with
|
|
188
|
+
#
|
|
189
|
+
# @api private
|
|
190
|
+
# @param value [String] the username, with or without a leading at sign
|
|
191
|
+
# @return [String] the username
|
|
192
|
+
def username(value) = value.to_s.delete_prefix("@")
|
|
193
|
+
|
|
194
|
+
# Normalize a username, which must be one, so nothing else reaches a path
|
|
195
|
+
#
|
|
196
|
+
# @api private
|
|
197
|
+
# @param value [String] the username, with or without a leading at sign
|
|
198
|
+
# @return [String] the username, without the at sign
|
|
199
|
+
# @raise [ArgumentError] if the value is not one to fifteen word characters
|
|
200
|
+
def username!(value)
|
|
201
|
+
name = value.to_s
|
|
202
|
+
return name.delete_prefix("@") if name.match?(USERNAME)
|
|
203
|
+
|
|
204
|
+
raise ArgumentError, "#{value.inspect} is not a username: pass one to fifteen letters, digits, or underscores"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# The identifier of the user a client's credentials name, when they name one
|
|
208
|
+
#
|
|
209
|
+
# The client's authenticator is where a client keeps the credentials it signs with, and it answers the user
|
|
210
|
+
# they act for; only an OAuth 1.0a access token names one, since it begins with the identifier of the user who
|
|
211
|
+
# authorized it. The secrets a client signs with are its authenticator's to keep, so this asks for the user
|
|
212
|
+
# rather than for a credential to read it out of. An authenticator of another's making may answer the identifier
|
|
213
|
+
# as the String of digits it read off a token, so it is read as an Integer, as every identifier is, and compared
|
|
214
|
+
# with the identifiers of users as one.
|
|
215
|
+
#
|
|
216
|
+
# @api private
|
|
217
|
+
# @param client [Object] the client, whose credentials may name a user
|
|
218
|
+
# @return [Integer, nil] the identifier, or nil if the client's credentials name no user
|
|
219
|
+
# @raise [ArgumentError] if the authenticator answers a user_id that is not an identifier
|
|
220
|
+
def authenticated_user_id(client)
|
|
221
|
+
authenticator = authenticator_of(client)
|
|
222
|
+
Shape.integer(authenticator.user_id) if authenticator.respond_to?(:user_id)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# The identifier of the user a lookup found for a client's credentials
|
|
226
|
+
#
|
|
227
|
+
# X::Client keeps it with memoize, under a lock, for the authenticator it holds, and a frozen client keeps it
|
|
228
|
+
# too. Any class can include X::Resources::API, so a client that does not memoize keeps it in an instance variable
|
|
229
|
+
# named for this gem, rather than one named @current_user_id, as an application often names its own, which
|
|
230
|
+
# would be overwritten.
|
|
231
|
+
#
|
|
232
|
+
# @api private
|
|
233
|
+
# @param client [Object] the client
|
|
234
|
+
# @return [Integer, nil] the identifier, or nil if none was found for the authenticator the client holds
|
|
235
|
+
def remembered_user_id(client)
|
|
236
|
+
return client.memoized(CURRENT_USER_ID) if client.respond_to?(:memoized)
|
|
237
|
+
|
|
238
|
+
owner, id = client.instance_variable_get(:@x_resources_current_user_id)
|
|
239
|
+
id if owner.equal?(authenticator_of(client))
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Keep the identifier of the authenticated user, with the authenticator
|
|
243
|
+
#
|
|
244
|
+
# A frozen client that does not memoize keeps nothing.
|
|
245
|
+
#
|
|
246
|
+
# @api private
|
|
247
|
+
# @param client [Object] the client
|
|
248
|
+
# @param id [Integer] the identifier
|
|
249
|
+
# @return [void]
|
|
250
|
+
def remember_user_id(client, id)
|
|
251
|
+
if client.respond_to?(:memoize)
|
|
252
|
+
client.memoize(CURRENT_USER_ID, id)
|
|
253
|
+
elsif !client.frozen?
|
|
254
|
+
client.instance_variable_set(:@x_resources_current_user_id, [authenticator_of(client), id])
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# The authenticator of a client, which is replaced whenever its credentials change
|
|
259
|
+
#
|
|
260
|
+
# @api private
|
|
261
|
+
# @param client [Object] the client, which may have an authenticator
|
|
262
|
+
# @return [Object, nil] the authenticator, or nil if the client has none
|
|
263
|
+
def authenticator_of(client)
|
|
264
|
+
client.authenticator if client.respond_to?(:authenticator)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# The client for an endpoint that refuses OAuth 1.0a
|
|
268
|
+
#
|
|
269
|
+
# A space endpoint refuses it, and so does one that takes app-only authentication alone, as the count of the
|
|
270
|
+
# full archive does. A client that signs with OAuth 1.0a requests as the app, with a copy that reuses its bearer token, as one
|
|
271
|
+
# signed in with OAuth 2.0 as a user that holds the app's credentials does. One that holds none, and so has no
|
|
272
|
+
# app-only client, requests as the user, and the API answers as it answers those credentials: an endpoint that
|
|
273
|
+
# takes OAuth 2.0 user authentication answers it, and one that takes app-only authentication alone refuses it
|
|
274
|
+
# with 403 Forbidden, which raises X::Forbidden, as any request refused for its credentials does.
|
|
275
|
+
#
|
|
276
|
+
# @api private
|
|
277
|
+
# @param client [Object] the client
|
|
278
|
+
# @return [Object] the client's app-only client, which reuses its bearer token, or the client itself
|
|
279
|
+
def app_client(client)
|
|
280
|
+
client.respond_to?(:app_only) ? client.app_only : client
|
|
281
|
+
rescue UnsupportedOperation
|
|
282
|
+
client
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Check whether a value identifies a resource rather than naming one
|
|
286
|
+
#
|
|
287
|
+
# An Integer is an identifier and a String is a name, such as a username, so that an
|
|
288
|
+
# account whose username is all digits is looked up as the name it is.
|
|
289
|
+
#
|
|
290
|
+
# @api private
|
|
291
|
+
# @param value [Resource, String, Integer] a resource, an identifier, or a username
|
|
292
|
+
# @return [Boolean] true if the value is a resource or an Integer identifier
|
|
293
|
+
def id?(value)
|
|
294
|
+
value.is_a?(Resource) || value.instance_of?(Integer)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Read a value of the data of the response to a write
|
|
298
|
+
#
|
|
299
|
+
# It is such a value as whether a user is now followed.
|
|
300
|
+
#
|
|
301
|
+
# @api private
|
|
302
|
+
# @param body [Hash, nil] the parsed body of the response
|
|
303
|
+
# @param key [String] the key of the value in the data
|
|
304
|
+
# @return [Object, nil] the value, or nil if the response holds no data or no such value
|
|
305
|
+
# @raise [InvalidAttribute] if the data of the response is not an object
|
|
306
|
+
# @example Read whether a post was deleted
|
|
307
|
+
# X::Resources::Utils.written(body, "deleted")
|
|
308
|
+
def written(body, key) = Shape.read_object("the data of the response", body.to_h["data"])&.[](key)
|
|
309
|
+
|
|
310
|
+
# Read a value of a response, which must be what the API documents it to be
|
|
311
|
+
#
|
|
312
|
+
# @api private
|
|
313
|
+
# @param name [String] what the value is, such as the reader that reads it
|
|
314
|
+
# @param value [Object] the value the response holds
|
|
315
|
+
# @yieldparam value [Object] the value
|
|
316
|
+
# @return [Object] what the block returns
|
|
317
|
+
# @raise [InvalidAttribute] if the block raises ArgumentError for the value
|
|
318
|
+
# @example Read the time a post was created
|
|
319
|
+
# X::Resources::Utils.read("X::Post#created_at", attrs["created_at"]) { |value| X::Resources::Utils.time(value) }
|
|
320
|
+
def read(name, value)
|
|
321
|
+
yield value
|
|
322
|
+
rescue ArgumentError
|
|
323
|
+
raise InvalidAttribute, "#{name} cannot be read from #{value.inspect}"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Deconstruct a value into what its readers read, so it matches a hash pattern
|
|
327
|
+
#
|
|
328
|
+
# A pattern that asks for every key gets each reader by the name it is declared by, and one that names keys
|
|
329
|
+
# gets those of them it names, by any name they are read by, as a resource matches a pattern.
|
|
330
|
+
#
|
|
331
|
+
# @api private
|
|
332
|
+
# @param value [Object] the value, which answers each reader
|
|
333
|
+
# @param keys [Array<Symbol>, nil] the keys the pattern asks for, or nil for every reader
|
|
334
|
+
# @param names [Array<Symbol>] the readers
|
|
335
|
+
# @param aliases [Array<Symbol>] the other names some of the readers are read by
|
|
336
|
+
# @return [Hash{Symbol => Object}] what the readers the pattern asks for read
|
|
337
|
+
# @example Deconstruct a trend
|
|
338
|
+
# X::Resources::Utils.deconstruct(trend, [:name], %i[name post_count], %i[tweet_count]) # => {name: "#ruby"}
|
|
339
|
+
def deconstruct(value, keys, names, aliases = [])
|
|
340
|
+
names = (names + aliases) & keys unless keys.nil?
|
|
341
|
+
names.to_h { |name| [name, value.public_send(name)] }
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Parse an ISO 8601 timestamp
|
|
345
|
+
#
|
|
346
|
+
# A value that is not a String, such as a number, is not ISO 8601 either, and raises ArgumentError as one.
|
|
347
|
+
#
|
|
348
|
+
# @api private
|
|
349
|
+
# @param value [String, nil] the timestamp
|
|
350
|
+
# @return [Time, nil] the parsed time or nil if the timestamp is missing
|
|
351
|
+
# @raise [ArgumentError] if the timestamp is not ISO 8601
|
|
352
|
+
def time(value)
|
|
353
|
+
Time.iso8601(value.to_s) unless value.nil?
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
private_constant :Utils
|
|
357
|
+
end
|
|
358
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X
|
|
4
|
+
module Resources
|
|
5
|
+
# Equality of what an API response held that has no identifier, by its class and attributes
|
|
6
|
+
#
|
|
7
|
+
# A trend or the usage of a project has no identifier to tell it by, and a rule a post matched is told by its tag
|
|
8
|
+
# as well as its identifier, so two are equal when they are of the same class and hold the same attributes, and
|
|
9
|
+
# equal ones share a hash, so that uniq and a Hash key tell them apart. Every attribute counts, so a topic that
|
|
10
|
+
# trends in two places, with a count of posts in each, is two trends that differ; compare their names to find the
|
|
11
|
+
# topics two places share, as trends.map(&:name).intersect?(other_trends.map(&:name)).
|
|
12
|
+
#
|
|
13
|
+
# Internal to x-resources: the methods it gives X::Trend, X::PersonalizedTrend, X::PostUsage, and X::MatchingRule are
|
|
14
|
+
# public API, but the module is only how they are shared, and which classes include it can change within 1.x.
|
|
15
|
+
#
|
|
16
|
+
# @api semipublic
|
|
17
|
+
module ValueEquality
|
|
18
|
+
# Check whether another object holds the same attributes
|
|
19
|
+
#
|
|
20
|
+
# @api public
|
|
21
|
+
# @param other [Object] the other object
|
|
22
|
+
# @return [Boolean] true if the other is of the same class and holds the same attributes
|
|
23
|
+
# @example Check whether the trends of the world changed since they were last read, a count included
|
|
24
|
+
# X::Trend.at(1, client: client) == trends
|
|
25
|
+
def ==(other) = other.instance_of?(self.class) && attrs.eql?((_ = other).attrs)
|
|
26
|
+
alias_method :eql?, :==
|
|
27
|
+
|
|
28
|
+
# The hash of the object, which equal objects share
|
|
29
|
+
#
|
|
30
|
+
# @api public
|
|
31
|
+
# @return [Integer] the hash
|
|
32
|
+
# @example Collect the distinct rules the posts of a stream matched
|
|
33
|
+
# posts.flat_map(&:matching_rules).uniq
|
|
34
|
+
def hash = [self.class, attrs].hash
|
|
35
|
+
end
|
|
36
|
+
private_constant :ValueEquality
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
module Resources
|
|
7
|
+
# The state Marshal writes and reads of what an API response held that is not a resource
|
|
8
|
+
#
|
|
9
|
+
# A trend, the usage of a project, and a rule a post matched hold their attributes alone, so what is written is
|
|
10
|
+
# those attributes, led by the number of their format, as a resource is written, and what is read is built of
|
|
11
|
+
# them as the constructor builds it, frozen.
|
|
12
|
+
#
|
|
13
|
+
# Internal to x-resources: the methods it gives X::Trend, X::PersonalizedTrend, X::PostUsage, and X::MatchingRule,
|
|
14
|
+
# marshal_dump, marshal_load, encode_with, and init_with, are public API, but the module is only how they are shared,
|
|
15
|
+
# and which classes include it can change within 1.x.
|
|
16
|
+
#
|
|
17
|
+
# @api semipublic
|
|
18
|
+
module ValueMarshalling
|
|
19
|
+
# The number of the format of the state Marshal writes, which every release of 1.x writes
|
|
20
|
+
#
|
|
21
|
+
# 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
|
|
22
|
+
# Hash it does not read, so that the state one release of 1.x writes is read by every other, earlier or later.
|
|
23
|
+
#
|
|
24
|
+
# @api private
|
|
25
|
+
MARSHAL_FORMAT = 1
|
|
26
|
+
# The name YAML writes each part of the state under, in the order Marshal writes them
|
|
27
|
+
# @api private
|
|
28
|
+
YAML_KEYS = %w[format attrs].freeze
|
|
29
|
+
private_constant :MARSHAL_FORMAT, :YAML_KEYS
|
|
30
|
+
|
|
31
|
+
# The state Marshal writes
|
|
32
|
+
#
|
|
33
|
+
# What is written is plain data, led by the number of its format, so that a value written by one release of 1.x
|
|
34
|
+
# is read by a later one: its attributes, as the API sent them.
|
|
35
|
+
#
|
|
36
|
+
# @api public
|
|
37
|
+
# @return [Array(Integer, Hash{String => Object})] the number of the format, then the attributes
|
|
38
|
+
# @example Cache the trends of a place
|
|
39
|
+
# Rails.cache.write("trends", X::Trend.at(1, client: client))
|
|
40
|
+
def marshal_dump = [MARSHAL_FORMAT, attrs]
|
|
41
|
+
|
|
42
|
+
# Restore a value Marshal read, frozen as the value that was written was
|
|
43
|
+
#
|
|
44
|
+
# @api public
|
|
45
|
+
# @param state [Array] the state Marshal wrote
|
|
46
|
+
# @return [void]
|
|
47
|
+
# @raise [UnsupportedFormat] if the state is of a format this release does not read
|
|
48
|
+
# @example Read cached trends
|
|
49
|
+
# Marshal.load(Marshal.dump(trend)).name
|
|
50
|
+
def marshal_load(state)
|
|
51
|
+
format, attrs = state
|
|
52
|
+
raise UnsupportedFormat, "#{self.class} reads format #{MARSHAL_FORMAT} of Marshal, not #{format.inspect}" unless MARSHAL_FORMAT.eql?(format)
|
|
53
|
+
|
|
54
|
+
restore(attrs)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Write the state Marshal writes as YAML
|
|
58
|
+
#
|
|
59
|
+
# YAML would write the instance variables of the value, and read them back into one that is not frozen, so it says
|
|
60
|
+
# how it is written: each part of the state Marshal writes, under its name.
|
|
61
|
+
#
|
|
62
|
+
# @api public
|
|
63
|
+
# @param coder [Psych::Coder] the coder YAML writes the value with
|
|
64
|
+
# @return [void]
|
|
65
|
+
# @example Write a value as YAML
|
|
66
|
+
# YAML.dump(trend)
|
|
67
|
+
def encode_with(coder) = YAML_KEYS.zip(marshal_dump) { |key, value| coder[key] = value }
|
|
68
|
+
|
|
69
|
+
# Restore a value YAML read, frozen, as Marshal restores one
|
|
70
|
+
#
|
|
71
|
+
# @api public
|
|
72
|
+
# @param coder [Psych::Coder] the coder YAML read the value with
|
|
73
|
+
# @return [void]
|
|
74
|
+
# @raise [UnsupportedFormat] if the state is of a format this release does not read
|
|
75
|
+
# @example Read a value written as YAML
|
|
76
|
+
# YAML.unsafe_load(YAML.dump(trend)).name
|
|
77
|
+
def init_with(coder) = marshal_load(coder.map.values_at(*YAML_KEYS))
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# Build the value of the attributes Marshal read, as its constructor builds it
|
|
82
|
+
# @api private
|
|
83
|
+
# @param attrs [Hash{String => Object}] the attributes
|
|
84
|
+
# @return [void]
|
|
85
|
+
def restore(attrs) = initialize(attrs) # steep:ignore UnexpectedPositionalArgument
|
|
86
|
+
end
|
|
87
|
+
private_constant :ValueMarshalling
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems/version"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# The object layer of the X gem
|
|
7
|
+
# @api public
|
|
8
|
+
module Resources
|
|
9
|
+
# The current version of the x-resources gem
|
|
10
|
+
# @api public
|
|
11
|
+
VERSION = "1.0.0"
|
|
12
|
+
|
|
13
|
+
# The version as a Gem::Version, which compares one release with another
|
|
14
|
+
#
|
|
15
|
+
# VERSION is a String, as a version constant is throughout Ruby, so that what reads it can split it, match it,
|
|
16
|
+
# or send it wherever a String belongs. This builds the Gem::Version that compares it with another version,
|
|
17
|
+
# which a String compares by character rather than by segment.
|
|
18
|
+
#
|
|
19
|
+
# @api public
|
|
20
|
+
# @return [Gem::Version] the version
|
|
21
|
+
# @example Take a path that a later release opened
|
|
22
|
+
# X::Resources.gem_version >= Gem::Version.new("1.1")
|
|
23
|
+
def self.gem_version = Gem::Version.new(VERSION)
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/x/resources.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "x/core"
|
|
4
|
+
require_relative "resources/version"
|
|
5
|
+
require_relative "resources/errors"
|
|
6
|
+
require_relative "resources/api"
|
|
7
|
+
require_relative "resources/relationships"
|
|
8
|
+
require_relative "resources/bookmark_folder"
|
|
9
|
+
require_relative "resources/community"
|
|
10
|
+
require_relative "resources/cursor"
|
|
11
|
+
require_relative "resources/direct_message"
|
|
12
|
+
require_relative "resources/list"
|
|
13
|
+
require_relative "resources/media"
|
|
14
|
+
require_relative "resources/place"
|
|
15
|
+
require_relative "resources/poll"
|
|
16
|
+
require_relative "resources/post"
|
|
17
|
+
require_relative "resources/space"
|
|
18
|
+
require_relative "resources/topic"
|
|
19
|
+
require_relative "resources/trend"
|
|
20
|
+
require_relative "resources/personalized_trend"
|
|
21
|
+
require_relative "resources/user"
|
|
22
|
+
require_relative "resources/post_usage"
|
data/sig/manifest.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# The standard libraries the signatures of x-resources refer to, which rbs collection loads for code that depends on it
|
|
2
|
+
#
|
|
3
|
+
# rbs collection reads every library named here as a standard library, so the gems x-resources depends on are left to its
|
|
4
|
+
# gemspec, from which it installs their signatures.
|
|
5
|
+
dependencies:
|
|
6
|
+
- name: json
|
|
7
|
+
- name: uri
|