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,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