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,535 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "x/core"
|
|
5
|
+
require_relative "abstract_class"
|
|
6
|
+
require_relative "attributes"
|
|
7
|
+
require_relative "published_count"
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
require_relative "identity"
|
|
10
|
+
require_relative "includes"
|
|
11
|
+
require_relative "marshalling"
|
|
12
|
+
require_relative "memo"
|
|
13
|
+
require_relative "serialization"
|
|
14
|
+
require_relative "utils"
|
|
15
|
+
|
|
16
|
+
module X
|
|
17
|
+
module Resources
|
|
18
|
+
# Base class for immutable API resources with identity, references, and hydration
|
|
19
|
+
#
|
|
20
|
+
# A reader of an object the API nests in a resource, or of a list of them, such as the entities, urls,
|
|
21
|
+
# public_metrics, edit_controls, attachments, and withheld of a post, the variants of media, the options of a poll,
|
|
22
|
+
# or the subscription and affiliation of a user, returns it as the API sends it: a frozen Hash keyed by String, or
|
|
23
|
+
# an Array of them. Each returns that throughout 1.x, and raises InvalidAttribute for a response that holds
|
|
24
|
+
# anything else in its place. A reader that returns an object, as the matching_rules of a post and the topics of a
|
|
25
|
+
# space do, is only ever added under a new name, never in place of one of these.
|
|
26
|
+
#
|
|
27
|
+
# @api public
|
|
28
|
+
class ::X::Resource
|
|
29
|
+
extend AbstractClass
|
|
30
|
+
extend Attributes
|
|
31
|
+
include PublishedCount
|
|
32
|
+
include Identity
|
|
33
|
+
include Serialization
|
|
34
|
+
include Marshalling
|
|
35
|
+
|
|
36
|
+
# The mixins of the class by their full names, which YARD needs to resolve them in a class opened under X
|
|
37
|
+
#
|
|
38
|
+
# @!parse
|
|
39
|
+
# include X::Resources::Identity
|
|
40
|
+
# include X::Resources::Serialization
|
|
41
|
+
# include X::Resources::Marshalling
|
|
42
|
+
|
|
43
|
+
# The frozen attributes returned by the API
|
|
44
|
+
# @api public
|
|
45
|
+
# @return [Hash{String => Object}] the attributes
|
|
46
|
+
# @example Get the raw attributes
|
|
47
|
+
# user.attrs # => {"id" => "7505382", "name" => "Erik Berlin", "username" => "sferik"}
|
|
48
|
+
attr_reader :attrs
|
|
49
|
+
|
|
50
|
+
# The client used to fetch this resource and its references
|
|
51
|
+
# @api public
|
|
52
|
+
# @return [Object, nil] the client
|
|
53
|
+
# @example Get the client
|
|
54
|
+
# user.client
|
|
55
|
+
attr_reader :client
|
|
56
|
+
|
|
57
|
+
# The identity map of the response this resource came from
|
|
58
|
+
# @api private
|
|
59
|
+
# @return [Resources::Includes] the identity map
|
|
60
|
+
attr_reader :includes
|
|
61
|
+
private :includes
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
# The API endpoint used to look up this resource by identifier
|
|
65
|
+
#
|
|
66
|
+
# @api private
|
|
67
|
+
# @return [String, nil] the endpoint or nil if the resource cannot be looked up
|
|
68
|
+
# @example Get the endpoint
|
|
69
|
+
# X::User.__send__(:endpoint) # => "users"
|
|
70
|
+
def endpoint
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The attribute holding the identifier
|
|
74
|
+
#
|
|
75
|
+
# @api private
|
|
76
|
+
# @return [String] the identifier key
|
|
77
|
+
# @example Get the identifier key
|
|
78
|
+
# X::Media.__send__(:id_key) # => "media_key"
|
|
79
|
+
def id_key = "id"
|
|
80
|
+
|
|
81
|
+
# The type of the identifier, integer unless it is not a number
|
|
82
|
+
#
|
|
83
|
+
# @api private
|
|
84
|
+
# @return [Symbol] integer, or raw for an identifier that is not a number
|
|
85
|
+
# @example Get the identifier type
|
|
86
|
+
# X::Space.__send__(:id_type) # => :raw
|
|
87
|
+
def id_type = :integer
|
|
88
|
+
|
|
89
|
+
# The key under which this resource appears in the includes of a response
|
|
90
|
+
#
|
|
91
|
+
# @api private
|
|
92
|
+
# @return [String, nil] the includes key or nil if the resource is never expanded
|
|
93
|
+
# @example Get the includes key
|
|
94
|
+
# X::User.__send__(:includes_key) # => "users"
|
|
95
|
+
def includes_key
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The query parameter that selects the fields of this resource
|
|
99
|
+
#
|
|
100
|
+
# @api private
|
|
101
|
+
# @return [String, nil] the fields parameter or nil if the resource has no fields parameter
|
|
102
|
+
# @example Get the fields parameter
|
|
103
|
+
# X::User.__send__(:fields_key) # => "user.fields"
|
|
104
|
+
def fields_key
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Build a resource from an identifier, or from a resource, without a request
|
|
108
|
+
#
|
|
109
|
+
# @api public
|
|
110
|
+
# @param id [String, Integer, Resource] the identifier, or a resource of this class, whose identifier is taken
|
|
111
|
+
# @param client [Object, nil] the client used to fetch the resource and its references
|
|
112
|
+
# @return [Resource] a stub that hydrates to the full resource
|
|
113
|
+
# @raise [ArgumentError] if the identifier is not a number, for a resource whose identifiers are numbers, or the
|
|
114
|
+
# resource is of another class
|
|
115
|
+
# @example Page through the followers of a user without looking the user up
|
|
116
|
+
# X::User.from_id(7505382, client: client).followers
|
|
117
|
+
def from_id(id, client: nil) = from_id_in_batch(id, client:)
|
|
118
|
+
|
|
119
|
+
# Build a stub that hydrates with the stubs of a batch, in one lookup for them all
|
|
120
|
+
#
|
|
121
|
+
# Internal to x-resources: a cursor that reads nothing but identifiers builds its stubs with it, and it takes a
|
|
122
|
+
# Batch, which is internal too.
|
|
123
|
+
#
|
|
124
|
+
# @api private
|
|
125
|
+
# @param id [String, Integer, Resource] the identifier, or a resource of this class, whose identifier is taken
|
|
126
|
+
# @param client [Object, nil] the client used to fetch the resource and its references
|
|
127
|
+
# @param batch [Resources::Batch, nil] the batch the stub hydrates with
|
|
128
|
+
# @return [Resource] a stub that hydrates to the full resource
|
|
129
|
+
# @raise [ArgumentError] if the identifier is not a number, for a resource whose identifiers are numbers, or the
|
|
130
|
+
# resource is of another class
|
|
131
|
+
# @example Build the stub of a page of followers
|
|
132
|
+
# X::User.__send__(:from_id_in_batch, 7505382, client: client, batch: batch)
|
|
133
|
+
def from_id_in_batch(id, client:, batch: nil) = build({id_key => Utils.id_from(id, self)}, client:, batch:)
|
|
134
|
+
|
|
135
|
+
# Build a resource with the internals new keeps to itself
|
|
136
|
+
#
|
|
137
|
+
# Internal to x-resources: a response builds its resources over the identity map of its includes, and a cursor
|
|
138
|
+
# its stubs over a Batch, both of which are internal, so new takes neither.
|
|
139
|
+
#
|
|
140
|
+
# @api private
|
|
141
|
+
# @param attrs [Hash] the attributes, which must include the identifier
|
|
142
|
+
# @param client [Object, nil] the client used to fetch references
|
|
143
|
+
# @param includes [Resources::Includes] the identity map of the response the resource came from
|
|
144
|
+
# @param hydrated [Boolean] whether the resource holds every requested field
|
|
145
|
+
# @param batch [Resources::Batch, nil] the batch this stub hydrates with, in one lookup for every stub of the batch
|
|
146
|
+
# @return [Resource] a new resource
|
|
147
|
+
# @raise [ArgumentError] if the attributes do not include the identifier, or the identifier is not one
|
|
148
|
+
# @example Build a post over the includes of its response
|
|
149
|
+
# X::Post.__send__(:build, {"id" => "1", "author_id" => "9"}, client: client, includes: includes)
|
|
150
|
+
def build(attrs, client: nil, includes: Includes.new, hydrated: false, batch: nil)
|
|
151
|
+
allocate.tap { |resource| resource.__send__(:setup, attrs, client:, includes:, hydrated:, batch:) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The default query parameters requesting every field and expansion
|
|
155
|
+
#
|
|
156
|
+
# They are built from the FIELDS and EXPANSIONS of the classes they name, which a minor release may add to; see
|
|
157
|
+
# {Resource#hydrated?}.
|
|
158
|
+
#
|
|
159
|
+
# @api public
|
|
160
|
+
# @return [Hash{String => Array<String>}] the default query parameters
|
|
161
|
+
# @example Get the default parameters
|
|
162
|
+
# X::User.default_params
|
|
163
|
+
def default_params = {}
|
|
164
|
+
|
|
165
|
+
# Check whether a request asks for every default field and expansion
|
|
166
|
+
#
|
|
167
|
+
# A request that overrides a default parameter to leave out a field or an expansion it names builds resources
|
|
168
|
+
# that are not hydrated, so that hydrate fetches the full resource rather than return one that lacks fields. A
|
|
169
|
+
# request that asks for every one of them, in any order, and for more besides, such as non_public_metrics, is
|
|
170
|
+
# hydrated, so hydrate returns the resource it built, which holds the fields it added, rather than fetch one
|
|
171
|
+
# that lacks them.
|
|
172
|
+
#
|
|
173
|
+
# @api private
|
|
174
|
+
# @param query [Hash{String => Object}] the query parameters of the request, merged over the defaults
|
|
175
|
+
# @return [Boolean] true if every default parameter asks for every value it asks for by default
|
|
176
|
+
# @example Check a request that asks for the name of a user alone
|
|
177
|
+
# X::User.__send__(:fully_requested_by?, "user.fields" => "name") # => false
|
|
178
|
+
def fully_requested_by?(query)
|
|
179
|
+
Utils.query(default_params).all? { |key, value| (value.split(",") - query[key].to_s.split(",")).empty? }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The query parameter a batch lookup takes the identifiers in
|
|
183
|
+
#
|
|
184
|
+
# @api private
|
|
185
|
+
# @return [Symbol] the parameter name
|
|
186
|
+
# @example Get the parameter of a batch lookup of media
|
|
187
|
+
# X::Media.__send__(:batch_key) # => :media_keys
|
|
188
|
+
def batch_key = :ids
|
|
189
|
+
|
|
190
|
+
# The lookup endpoint, which must exist
|
|
191
|
+
#
|
|
192
|
+
# @api private
|
|
193
|
+
# @return [String] the endpoint
|
|
194
|
+
# @raise [UnsupportedOperation] if the resource cannot be looked up by identifier
|
|
195
|
+
# @example Get the lookup endpoint
|
|
196
|
+
# X::User.__send__(:endpoint!) # => "users"
|
|
197
|
+
def endpoint! = endpoint || raise(UnsupportedOperation, "#{self} cannot be fetched by #{id_key}")
|
|
198
|
+
|
|
199
|
+
private :endpoint, :id_key, :id_type, :includes_key, :fields_key, :from_id_in_batch, :build, :fully_requested_by?, :batch_key, :endpoint!
|
|
200
|
+
|
|
201
|
+
# Build the resource or resources a response holds
|
|
202
|
+
#
|
|
203
|
+
# A response whose data is an object builds one resource, and one whose data is an array builds a Page of one
|
|
204
|
+
# for each element, which holds the meta of the response, such as its next_token, and the problems it
|
|
205
|
+
# reported. A list the API finds empty holds no data, only a meta, which may still name the token of a page
|
|
206
|
+
# after it, so a response with a meta and no data builds an empty Page. A lookup of several resources by their
|
|
207
|
+
# identifiers, such as users?ids=, that finds none of them holds no data and no meta, only a problem for each,
|
|
208
|
+
# which names the ids, media_keys, or usernames parameter, so it builds an empty Page of those problems, as a
|
|
209
|
+
# lookup that finds some of them builds a Page of those it found. A client calls this when a resource class is
|
|
210
|
+
# the object_class of a request.
|
|
211
|
+
# A later version of x-core may pass it keywords of its own, which are ignored, as X::Client asks of
|
|
212
|
+
# what it calls from_response on.
|
|
213
|
+
#
|
|
214
|
+
# A response holds only the fields its request asked for, so what this builds is not hydrated
|
|
215
|
+
# unless told otherwise, and hydrate fetches the full resource.
|
|
216
|
+
#
|
|
217
|
+
# @api public
|
|
218
|
+
# @param body [Hash, nil] the parsed response body
|
|
219
|
+
# @param client [Object] the client used to make the request
|
|
220
|
+
# @param hydrated [Boolean] whether the response holds every field the object layer requests
|
|
221
|
+
# @return [Resource, Page, nil] the resource, or the page of resources, or nil if the response has no data, no
|
|
222
|
+
# meta, and no problem of a lookup of several
|
|
223
|
+
# @raise [InvalidAttribute] if the response holds a resource without an identifier, or with one that is not one
|
|
224
|
+
# @example Build a user from a response
|
|
225
|
+
# X::User.from_response({"data" => {"id" => "7505382"}}, client: client)
|
|
226
|
+
# @example Build users from a client request, and read the token of the next page
|
|
227
|
+
# client.get("users/7505382/blocking", object_class: X::User).next_token
|
|
228
|
+
def from_response(body, client:, hydrated: false, **)
|
|
229
|
+
return collection_from_response(body, client:, hydrated:) if Page.__send__(:list?, body.to_h)
|
|
230
|
+
|
|
231
|
+
resource_from_response(body, client:, hydrated:)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Build a resource from a response with a single data object
|
|
235
|
+
#
|
|
236
|
+
# A line of the filtered stream holds the rules its post matched beside its data, as matching_rules, which the
|
|
237
|
+
# resource keeps among its attributes, where X::Post#matching_rules reads them.
|
|
238
|
+
#
|
|
239
|
+
# Internal to the object layer: from_response, which a client calls, builds what a response holds, and takes
|
|
240
|
+
# the keywords a later version of x-core may pass it, where this does not.
|
|
241
|
+
#
|
|
242
|
+
# @api private
|
|
243
|
+
# @param body [Hash, nil] the parsed response body
|
|
244
|
+
# @param client [Object] the client used to make the request
|
|
245
|
+
# @param hydrated [Boolean] whether the response holds every field the object layer requests
|
|
246
|
+
# @return [Resource, nil] the resource or nil if the response has no data
|
|
247
|
+
# @raise [InvalidAttribute] if the response holds a resource without an identifier, or with one that is not one
|
|
248
|
+
# @example Build a user from a response
|
|
249
|
+
# X::User.__send__(:resource_from_response, {"data" => {"id" => "7505382"}}, client: client)
|
|
250
|
+
private def resource_from_response(body, client:, hydrated: false) = resource_built_from(body, client:, hydrated:, query: nil)
|
|
251
|
+
|
|
252
|
+
# Build resources from a response with a data array
|
|
253
|
+
#
|
|
254
|
+
# Internal to the object layer, as resource_from_response is.
|
|
255
|
+
#
|
|
256
|
+
# @api private
|
|
257
|
+
# @param body [Hash, nil] the parsed response body
|
|
258
|
+
# @param client [Object] the client used to make the request
|
|
259
|
+
# @param hydrated [Boolean] whether the response holds every field the object layer requests
|
|
260
|
+
# @return [Page] the page of resources, with the meta and problems of the response
|
|
261
|
+
# @raise [InvalidAttribute] if the response holds a resource without an identifier, or with one that is not one,
|
|
262
|
+
# or a meta that is not an object
|
|
263
|
+
# @example Build users from a response
|
|
264
|
+
# X::User.__send__(:collection_from_response, {"data" => [{"id" => "7505382"}]}, client: client)
|
|
265
|
+
private def collection_from_response(body, client:, hydrated: false) = Page.new(collection_built_from(body, client:, hydrated:, query: nil), meta: Page.__send__(:meta_of, body), problems: Problem.all_from(body))
|
|
266
|
+
|
|
267
|
+
# Build a resource from a response, knowing the query of its request
|
|
268
|
+
#
|
|
269
|
+
# Internal to the object layer: the query, which the lookups and cursors of the object layer know, tells whether
|
|
270
|
+
# the resources the response included are hydrated, and which of the fields that tells by can change within 1.x.
|
|
271
|
+
#
|
|
272
|
+
# @api private
|
|
273
|
+
# @param body [Hash, nil] the parsed response body
|
|
274
|
+
# @param client [Object] the client used to make the request
|
|
275
|
+
# @param hydrated [Boolean] whether the response holds every field the object layer requests
|
|
276
|
+
# @param query [Hash{String => Object}, nil] the query parameters of the request, merged over the defaults,
|
|
277
|
+
# which tell whether the resources it included are hydrated, or nil if they are not known
|
|
278
|
+
# @return [Resource, nil] the resource or nil if the response has no data
|
|
279
|
+
# @raise [InvalidAttribute] if the response holds a resource without an identifier, or with one that is not one
|
|
280
|
+
private def resource_built_from(body, client:, hydrated:, query:)
|
|
281
|
+
body = body.to_h
|
|
282
|
+
data = body["data"]
|
|
283
|
+
return unless data.is_a?(Hash)
|
|
284
|
+
|
|
285
|
+
built(data.merge(body.slice("matching_rules")), client:, includes: Includes.new(body["includes"], problems: Problem.all_from(body), query:), hydrated:)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Build resources from a response, knowing the query of its request
|
|
289
|
+
#
|
|
290
|
+
# Internal to the object layer, as resource_built_from is.
|
|
291
|
+
#
|
|
292
|
+
# @api private
|
|
293
|
+
# @param body [Hash, nil] the parsed response body
|
|
294
|
+
# @param client [Object] the client used to make the request
|
|
295
|
+
# @param hydrated [Boolean] whether the response holds every field the object layer requests
|
|
296
|
+
# @param query [Hash{String => Object}, nil] the query parameters of the request, merged over the defaults,
|
|
297
|
+
# which tell whether the resources it included are hydrated, or nil if they are not known
|
|
298
|
+
# @return [Array<Resource>] the resources
|
|
299
|
+
# @raise [InvalidAttribute] if the response holds a resource without an identifier, or with one that is not one
|
|
300
|
+
private def collection_built_from(body, client:, hydrated:, query:)
|
|
301
|
+
body = body.to_h
|
|
302
|
+
data = Array.try_convert(body["data"])
|
|
303
|
+
includes = Includes.new(body["includes"], problems: Problem.all_from(body), query:)
|
|
304
|
+
Array(data).map { |attrs| built(attrs, client:, includes:, hydrated:) }.freeze
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Build a resource a response holds, whose identifier must be one
|
|
308
|
+
# @api private
|
|
309
|
+
# @param attrs [Hash] the attributes the response holds
|
|
310
|
+
# @param client [Object, nil] the client used to fetch references
|
|
311
|
+
# @param includes [Resources::Includes] the identity map of the response
|
|
312
|
+
# @param hydrated [Boolean] whether the resource holds every requested field
|
|
313
|
+
# @return [Resource] the resource
|
|
314
|
+
# @raise [InvalidAttribute] if the attributes are not an object, or hold no identifier, or hold one that is not one
|
|
315
|
+
private def built(attrs, client:, includes:, hydrated:) = Utils.read("#{self}##{id_key}", Hash.try_convert(attrs)&.[](id_key)) { build(Shape.object!(attrs), client:, includes:, hydrated:) }
|
|
316
|
+
end
|
|
317
|
+
private_class_method(*AbstractClass::BUILDERS)
|
|
318
|
+
|
|
319
|
+
# Initialize a new immutable resource
|
|
320
|
+
#
|
|
321
|
+
# @api public
|
|
322
|
+
# @param attrs [Hash] the attributes, which must include the identifier
|
|
323
|
+
# @param client [Object, nil] the client used to fetch references
|
|
324
|
+
# @param hydrated [Boolean] whether the resource holds every requested field
|
|
325
|
+
# @return [Resource] a new resource
|
|
326
|
+
# @raise [ArgumentError] if the attributes are not a Hash, do not include the identifier, or hold an identifier that
|
|
327
|
+
# is not one
|
|
328
|
+
# @example Create a user from attributes
|
|
329
|
+
# X::User.new({"id" => "7505382", "username" => "sferik"}, client: client)
|
|
330
|
+
def initialize(attrs, client: nil, hydrated: false) = setup(Utils.attributes!(attrs), client:, hydrated:)
|
|
331
|
+
|
|
332
|
+
# The identifier
|
|
333
|
+
#
|
|
334
|
+
# @api public
|
|
335
|
+
# @return [Integer, String] the identifier, an Integer unless the resource's identifiers are not numbers
|
|
336
|
+
# @example Get the identifier
|
|
337
|
+
# user.id # => 7505382
|
|
338
|
+
def id
|
|
339
|
+
Attributes::CONVERTERS.fetch(self.class.__send__(:id_type)).call(attrs.fetch(self.class.__send__(:id_key)))
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Check whether the resource holds every field the object layer requests
|
|
343
|
+
#
|
|
344
|
+
# A resource is hydrated when it was the subject of a response to a request that asked for every default field
|
|
345
|
+
# and expansion, whether or not it asked for more, or a reference a response included that expands nothing, such
|
|
346
|
+
# as media, a poll, a place, or a topic, when the request asked for every default field of it. A stub, any other
|
|
347
|
+
# reference a response included, and a resource looked up with parameters that leave out some of those defaults
|
|
348
|
+
# are not, so hydrate fetches the full resource.
|
|
349
|
+
#
|
|
350
|
+
# The defaults are the FIELDS and EXPANSIONS of each class, which a minor release may add to as the API adds
|
|
351
|
+
# fields and expansions, so that a lookup with the defaults asks for them too. A resource looked up with a list
|
|
352
|
+
# of its own, even one that named every field of the release it was written for, then leaves out what was added,
|
|
353
|
+
# so it is no longer hydrated, and hydrate costs a lookup of the full resource that the same code did not pay
|
|
354
|
+
# before. To ask for more than the defaults, add to what default_params gives rather than list every value.
|
|
355
|
+
#
|
|
356
|
+
# @api public
|
|
357
|
+
# @return [Boolean] true if the resource holds every field the object layer requests
|
|
358
|
+
# @example Check whether a referenced user is hydrated
|
|
359
|
+
# post.author.hydrated? # => false
|
|
360
|
+
def hydrated?
|
|
361
|
+
@hydrated
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# The problems the API reported about this resource in the response it came from
|
|
365
|
+
#
|
|
366
|
+
# A problem is about the resource when the identifier it names, as its resource_id or its value, is the
|
|
367
|
+
# identifier of the resource or of one the resource refers to directly, such as the author of a post, or the
|
|
368
|
+
# pinned post of a user, so each post of a page reports that its own author no longer exists, and none reports
|
|
369
|
+
# it of another. A problem that names no identifier could be about any resource of the response, so every one
|
|
370
|
+
# of them reports it. The page of a cursor reports every problem of its response, as a finder yields them.
|
|
371
|
+
#
|
|
372
|
+
# @api public
|
|
373
|
+
# @return [Array<Problem>] the problems, such as expansions whose resources no longer exist
|
|
374
|
+
# @example Check whether a user's pinned post still exists
|
|
375
|
+
# client.current_user!.problems.select(&:not_found?)
|
|
376
|
+
def problems = includes.problems_about([id, *self.class.__send__(:referenced_ids, attrs)])
|
|
377
|
+
|
|
378
|
+
# Check whether the resource holds nothing but its identifier
|
|
379
|
+
#
|
|
380
|
+
# A reference the response did not expand is a stub, and so is a resource built with from_id.
|
|
381
|
+
#
|
|
382
|
+
# @api public
|
|
383
|
+
# @return [Boolean] true if the resource holds only its identifier
|
|
384
|
+
# @example Check whether the author of a post was included in the response
|
|
385
|
+
# post.author.stub? # => false
|
|
386
|
+
def stub? = attrs.keys.eql?([self.class.__send__(:id_key)])
|
|
387
|
+
|
|
388
|
+
# Fetch the full resource, memoizing the result
|
|
389
|
+
#
|
|
390
|
+
# Each resource that is not hydrated costs a request of its own, so hydrate many resources, such as the authors
|
|
391
|
+
# of the posts of a page, with the hydrate_all of their class, which looks them up a hundred at a time, rather
|
|
392
|
+
# than call hydrate on each.
|
|
393
|
+
#
|
|
394
|
+
# @api public
|
|
395
|
+
# @return [Resource, nil] the full resource or nil if it no longer exists
|
|
396
|
+
# @raise [UnsupportedOperation] if the resource cannot be looked up by identifier, as a poll or a place cannot
|
|
397
|
+
# @raise [MissingClient] if the resource has no client
|
|
398
|
+
# @example Fetch the full user a stub names
|
|
399
|
+
# X::User.from_id(7_505_382, client: client).hydrate.description
|
|
400
|
+
# @example Fetch the full authors of many posts in batches, rather than a request for each
|
|
401
|
+
# X::User.hydrate_all(posts.map(&:author), client: client).map(&:description)
|
|
402
|
+
def hydrate
|
|
403
|
+
@memo.fetch { hydrated? ? self : fetch }
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Fetch the full resource again, replacing the memoized result
|
|
407
|
+
#
|
|
408
|
+
# A stub that hydrates together with the others of its page looks itself up on its own, rather than read what
|
|
409
|
+
# the lookup of the page found.
|
|
410
|
+
#
|
|
411
|
+
# @api public
|
|
412
|
+
# @return [Resource, nil] the fresh resource or nil if it no longer exists
|
|
413
|
+
# @raise [UnsupportedOperation] if the resource cannot be looked up by identifier, as a poll or a place cannot
|
|
414
|
+
# @raise [MissingClient] if the resource has no client
|
|
415
|
+
# @example Refresh a user's follower count
|
|
416
|
+
# user.refresh.followers_count
|
|
417
|
+
def refresh
|
|
418
|
+
@memo.store(look_up)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Summarize the resource for the console
|
|
422
|
+
#
|
|
423
|
+
# @api public
|
|
424
|
+
# @return [String] the class name and attributes
|
|
425
|
+
# @example Inspect a user
|
|
426
|
+
# user.inspect # => #<X::User id="7505382" username="sferik">
|
|
427
|
+
def inspect = "#<#{self.class} #{attrs.map { |key, value| "#{key}=#{value.inspect}" }.join(" ")}>"
|
|
428
|
+
|
|
429
|
+
private
|
|
430
|
+
|
|
431
|
+
# Set the attributes and internals of a new resource, and freeze it
|
|
432
|
+
# @api private
|
|
433
|
+
# @param attrs [Hash] the attributes, which must include the identifier
|
|
434
|
+
# @param client [Object, nil] the client used to fetch references
|
|
435
|
+
# @param hydrated [Boolean] whether the resource holds every requested field
|
|
436
|
+
# @param includes [Resources::Includes] the identity map of the response the resource came from
|
|
437
|
+
# @param batch [Resources::Batch, nil] the batch this stub hydrates with
|
|
438
|
+
# @return [void]
|
|
439
|
+
# @raise [ArgumentError] if the attributes do not include the identifier, or the identifier is not one
|
|
440
|
+
def setup(attrs, client:, hydrated:, includes: Includes.new, batch: nil)
|
|
441
|
+
@attrs = Utils.deep_freeze(attrs)
|
|
442
|
+
identify
|
|
443
|
+
@client, @includes, @hydrated, @batch = client, includes, hydrated, batch
|
|
444
|
+
@memo = Memo.new
|
|
445
|
+
freeze
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Store the full resource a lookup of many found, so hydrate reads it
|
|
449
|
+
#
|
|
450
|
+
# Internal to the object layer: hydrate_all calls it with __send__, since a caller that stored another
|
|
451
|
+
# resource would change what a frozen resource hydrates to.
|
|
452
|
+
#
|
|
453
|
+
# @api private
|
|
454
|
+
# @param resource [Resource, nil] the full resource, or nil if it no longer exists
|
|
455
|
+
# @return [Resource, nil] the resource that was stored
|
|
456
|
+
def hydrated_with(resource) = @memo.store(resource)
|
|
457
|
+
|
|
458
|
+
# Check whether hydrate would return what it stored, at the cost of no request
|
|
459
|
+
#
|
|
460
|
+
# Internal to the object layer: hydrate_all calls it with __send__, so that it looks up no resource that a
|
|
461
|
+
# lookup of many, or hydrate, already found.
|
|
462
|
+
#
|
|
463
|
+
# @api private
|
|
464
|
+
# @return [Boolean] true if hydrate or a lookup of many stored the full resource, or that it no longer exists
|
|
465
|
+
def hydration_stored? = @memo.stored?
|
|
466
|
+
|
|
467
|
+
# Read the identifier once, at the point the resource is made
|
|
468
|
+
#
|
|
469
|
+
# Attributes that hold no identifier, or hold one the API could not have given, would otherwise raise from a
|
|
470
|
+
# reader, an equality test, or a Hash the resource is a key of, far from where they were written.
|
|
471
|
+
#
|
|
472
|
+
# @api private
|
|
473
|
+
# @return [String] the identifier
|
|
474
|
+
# @raise [ArgumentError] if the attributes hold no identifier, or hold one that is not one
|
|
475
|
+
# @example Refuse a user whose identifier is not a number
|
|
476
|
+
# X::User.new({"id" => "abc"})
|
|
477
|
+
def identify
|
|
478
|
+
key = self.class.__send__(:id_key)
|
|
479
|
+
value = attrs[key]
|
|
480
|
+
raise ArgumentError, "#{self.class} requires #{key}" if value.nil?
|
|
481
|
+
|
|
482
|
+
Utils.id_of(value, self.class)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Fetch the full resource from the API, in the lookup of its batch if it has one
|
|
486
|
+
# @api private
|
|
487
|
+
# @return [Resource, nil] the full resource or nil if it no longer exists
|
|
488
|
+
def fetch
|
|
489
|
+
batch = @batch
|
|
490
|
+
batch.nil? ? look_up : batch.fetch(id)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Look the full resource up on its own
|
|
494
|
+
# @api private
|
|
495
|
+
# @return [Resource, nil] the full resource or nil if it no longer exists
|
|
496
|
+
def look_up = self.class.__send__(:locate, "#{self.class.__send__(:endpoint!)}/#{id}", client: client!)
|
|
497
|
+
|
|
498
|
+
# The client, which must exist
|
|
499
|
+
# @api private
|
|
500
|
+
# @return [Object] the client
|
|
501
|
+
# @raise [MissingClient] if the resource has no client
|
|
502
|
+
def client!
|
|
503
|
+
client || raise(MissingClient, "#{self.class} has no client")
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Resolve a referenced resource through the identity map of its response
|
|
507
|
+
# @api private
|
|
508
|
+
# @param klass [Class] the resource class
|
|
509
|
+
# @param id [String, nil] the identifier
|
|
510
|
+
# @return [Resource, nil] the resource or nil if the identifier is missing
|
|
511
|
+
# @raise [InvalidAttribute] if the identifier is not one
|
|
512
|
+
def resolve(klass, id)
|
|
513
|
+
Utils.read("The reference of #{self.class} to #{klass}", id) { includes.resolve(klass, id, client:) } unless id.nil?
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Build a cursor over a collection endpoint scoped to this resource
|
|
517
|
+
# @api private
|
|
518
|
+
# @param klass [Class] the resource class of the items
|
|
519
|
+
# @param path [String] the endpoint path
|
|
520
|
+
# @param max_results [Integer, nil] the maximum number of items per page, or nil for an endpoint without pages
|
|
521
|
+
# @param min_results [Integer] the smallest page the endpoint accepts
|
|
522
|
+
# @param total [Symbol, nil] the attribute holding the number of resources the API publishes
|
|
523
|
+
# @param app_only [Boolean] whether the endpoint takes app-only authentication, as a space endpoint does, so
|
|
524
|
+
# the pages are fetched with the app-only client of this resource's client
|
|
525
|
+
# @param ids_only [Boolean] whether the endpoint gives the resources by their identifiers alone, and takes none of
|
|
526
|
+
# their fields, so the pages ask for none, and read stubs
|
|
527
|
+
# @param params [Hash] query parameters merged over the default parameters
|
|
528
|
+
# @return [Cursor] the cursor
|
|
529
|
+
def cursor(klass, path, max_results:, min_results: 1, total: nil, app_only: false, ids_only: false, **params)
|
|
530
|
+
defaults = {max_results:} #: Hash[Symbol, untyped]
|
|
531
|
+
Cursor.__send__(:build, klass, path, client: client!, params: defaults.merge(params), min_results:, app_only:, total: counter(total), ids_only:)
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
module Resources
|
|
7
|
+
# Serialization of what an API response held, by its attributes alone
|
|
8
|
+
#
|
|
9
|
+
# ActiveSupport's Object#as_json would otherwise read the instance variables, which hold the client and so its
|
|
10
|
+
# credentials, and loop for good once a reference has resolved.
|
|
11
|
+
#
|
|
12
|
+
# Internal to x-resources: the methods it gives a resource, X::PostUsage, and the other values of the object layer, such
|
|
13
|
+
# as to_json, are public API, but the module is only how they are shared, and which classes extend or include it
|
|
14
|
+
# can change within 1.x.
|
|
15
|
+
#
|
|
16
|
+
# @api semipublic
|
|
17
|
+
module Serialization
|
|
18
|
+
# The attributes, or with a block the Hash of the pairs it returns for them
|
|
19
|
+
#
|
|
20
|
+
# With a block it is the to_h of the attributes, as a Hash builds it, rather than the attributes, the block ignored.
|
|
21
|
+
#
|
|
22
|
+
# @api public
|
|
23
|
+
# @yieldparam key [String] the name of each attribute
|
|
24
|
+
# @yieldparam value [Object] its value
|
|
25
|
+
# @yieldreturn [Array(Object, Object)] the key and value of the attribute in the Hash
|
|
26
|
+
# @return [Hash] the attributes, frozen, or the pairs the block returns
|
|
27
|
+
# @example Convert a resource to a hash
|
|
28
|
+
# user.to_h # => {"id" => "7505382", "username" => "sferik"}
|
|
29
|
+
# @example Key the attributes by Symbol
|
|
30
|
+
# user.to_h { |key, value| [key.to_sym, value] } # => {id: "7505382", username: "sferik"}
|
|
31
|
+
# @example Delete the rule a post matched
|
|
32
|
+
# streaming_client.delete_rules(post.matching_rules.first.to_h)
|
|
33
|
+
def to_h(&) = attrs.to_h(&) # steep:ignore BlockTypeMismatch
|
|
34
|
+
|
|
35
|
+
# The attributes, as a JSON encoder and ActiveSupport read them
|
|
36
|
+
#
|
|
37
|
+
# @api public
|
|
38
|
+
# @return [Hash{String => Object}] the attributes
|
|
39
|
+
# @example Serialize a resource
|
|
40
|
+
# user.as_json # => {"id" => "7505382", "username" => "sferik"}
|
|
41
|
+
# @example Serialize a problem
|
|
42
|
+
# problem.as_json # => {"title" => "Not Found Error"}
|
|
43
|
+
def as_json(*) = attrs
|
|
44
|
+
|
|
45
|
+
# The attributes as JSON
|
|
46
|
+
#
|
|
47
|
+
# @api public
|
|
48
|
+
# @param state [JSON::State, nil] the state a JSON encoder passes, which the attributes are given
|
|
49
|
+
# @return [String] the attributes as a JSON object
|
|
50
|
+
# @example Serialize a resource
|
|
51
|
+
# user.to_json # => "{\"id\":\"7505382\",\"username\":\"sferik\"}"
|
|
52
|
+
# @example Cache a resource as JSON
|
|
53
|
+
# Rails.cache.write("user", user.to_json)
|
|
54
|
+
def to_json(state = nil) = as_json.to_json(state)
|
|
55
|
+
end
|
|
56
|
+
private_constant :Serialization
|
|
57
|
+
end
|
|
58
|
+
end
|