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,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "x/core"
4
+ require_relative "errors"
5
+ require_relative "utils"
6
+
7
+ module X
8
+ module Resources
9
+ # Class methods that look resources up one at a time, extended into each resource class the API can look up
10
+ #
11
+ # A resource the API offers no lookup of, such as a poll or a place, does not extend it, and so answers none of
12
+ # its methods, rather than answer them only to raise. BatchFinders includes it for a resource the API can also
13
+ # look up many at a time.
14
+ #
15
+ # Internal to x-resources: the methods it gives a resource class, such as X::Post.find, are public API, but the module
16
+ # is only how they are shared, and which classes extend or include it can change within 1.x.
17
+ #
18
+ # @api semipublic
19
+ module Finders
20
+ # Look up a resource by identifier
21
+ #
22
+ # @api public
23
+ # @param id [String, Integer, Resource] the identifier, or a resource of this class
24
+ # @param client [Object] the client used to make the request
25
+ # @param params [Hash] query parameters merged over the default parameters; one that overrides a default field
26
+ # or expansion parameter to leave some out builds resources that are not hydrated, so hydrate fetches the rest
27
+ # @return [Resource, nil] the resource or nil if it was not found, whether the API answers 200 with no data or a
28
+ # 404 that reports the resource as not found
29
+ # @raise [ArgumentError] if the identifier is not one, or is a resource of another class, before a request
30
+ # @raise [X::NotFound] if the API answers any other 404, such as one from a client pointed at the wrong host or
31
+ # API version
32
+ # @yieldparam problem [Problem] each problem the API reported, such as a resource that was not found
33
+ # @example Look up a post by identifier
34
+ # X::Post.find(1234567890, client: client)
35
+ def find(id, client:, **params, &) = locate("#{endpoint!}/#{Utils.id_of(id, self)}", client:, **params, &)
36
+
37
+ # Look up a resource by identifier, which must exist
38
+ #
39
+ # The error it raises names the identifier looked up, whether the identifier or a resource was given. Its cause
40
+ # is the X::NotFound of a lookup the API answered with a 404 that reports the resource as not found.
41
+ #
42
+ # @api public
43
+ # @param id [String, Integer, Resource] the identifier, or a resource of this class
44
+ # @param client [Object] the client used to make the request
45
+ # @param params [Hash] query parameters merged over the default parameters; one that overrides a default field
46
+ # or expansion parameter to leave some out builds resources that are not hydrated, so hydrate fetches the rest
47
+ # @return [Resource] the resource
48
+ # @raise [ArgumentError] if the identifier is not one, or is a resource of another class, before a request
49
+ # @raise [MissingResource] if the resource was not found, whether the API answers 200 with no data or a 404 that
50
+ # reports the resource as not found
51
+ # @raise [X::NotFound] if the API answers any other 404, such as one from a client pointed at the wrong host or
52
+ # API version
53
+ # @example Look up a post by identifier
54
+ # X::Post.find!(1234567890, client: client)
55
+ def find!(id, client:, **params)
56
+ locate!("#{endpoint!}/#{Utils.id_of(id, self)}", Utils.id_from(id, self), client:, **params)
57
+ end
58
+
59
+ # Fetch a single resource from an endpoint
60
+ #
61
+ # Internal to x-resources: locate and the lookup of the authenticated user call it with the path of an endpoint,
62
+ # which names the API's own resources and can change within 1.x as the API does. It raises the NotFound of a
63
+ # 404, which only locate, whose path names one resource, reads as a resource that is missing, and only when the
64
+ # 404 reports it so.
65
+ #
66
+ # Data that holds no identifier is no resource: X answers the lookup of a user that does not exist, when it asks
67
+ # for a field the client may not read, such as parody for a client that authenticates as the app, with data that
68
+ # holds only the defaults of the fields it may, and the errors of those it may not, but no identifier.
69
+ #
70
+ # @api private
71
+ # @param path [String] the endpoint path
72
+ # @param client [Object] the client used to make the request
73
+ # @param params [Hash] query parameters merged over the default parameters; one that overrides a default field
74
+ # or expansion parameter to leave some out builds resources that are not hydrated, so hydrate fetches the rest
75
+ # @return [Resource, nil] the resource or nil if the response has no data, or data that holds no identifier
76
+ # @yieldparam problem [Problem] each problem the API reported
77
+ # @example Fetch the authenticated user
78
+ # X::User.__send__(:lookup, "users/me", client: client)
79
+ def lookup(path, client:, **params, &)
80
+ query = Utils.merge_params(default_params, params)
81
+ body = reporting(get(path, client:, query:), &)
82
+ resource_built_from(body, client:, hydrated: fully_requested_by?(query), query:) unless resourceless?(body)
83
+ end
84
+
85
+ # Fetch the resource a path names, which may be missing
86
+ #
87
+ # Internal to x-resources: the finders and hydrate call it with a path that ends in the identifier or the username
88
+ # of the resource, which they checked before building it. X documents both a 200 with no data and a 404 that
89
+ # reports the resource as not found as its answer to the lookup of a resource that is deleted, suspended, or was
90
+ # never there, so this reads the NotFound of the one as lookup reads the other: it finds nothing, and the block
91
+ # is given the problems the body of the 404 named.
92
+ #
93
+ # Any other 404 raises as it is, as every other failure does, since it says nothing of the resource: one that
94
+ # answers another request the client made for the lookup, such as the request for a token, or one whose body
95
+ # reports no resource as not found, such as that of a client pointed at the wrong host or API version.
96
+ #
97
+ # @api private
98
+ # @param path [String] the endpoint path, which ends in the identifier or the username of the resource
99
+ # @param client [Object] the client used to make the request
100
+ # @param params [Hash] query parameters merged over the default parameters; one that overrides a default field
101
+ # or expansion parameter to leave some out builds resources that are not hydrated, so hydrate fetches the rest
102
+ # @return [Resource, nil] the resource or nil if the API answers a 404 that reports the resource as not found,
103
+ # or a response that holds no resource
104
+ # @raise [X::NotFound] if the API answers any other 404
105
+ # @yieldparam problem [Problem] each problem the API reported
106
+ # @example Fetch a user that may not exist
107
+ # X::User.__send__(:locate, "users/7505382", client: client)
108
+ def locate(path, client:, **params, &)
109
+ lookup(path, client:, **params, &)
110
+ rescue X::NotFound => e
111
+ raise unless reports_missing?(e, path)
112
+
113
+ e.problems.each { |problem| yield problem } if block_given?
114
+ nil
115
+ end
116
+
117
+ # Fetch the resource a path names, which must exist
118
+ #
119
+ # Internal to x-resources: the finders that end in a bang call it with the path locate takes, and what the message
120
+ # of the error names the resource by. The error holds the problems of the response, and, when the API answers
121
+ # a 404 that reports the resource as not found, its cause is the NotFound that holds the response itself.
122
+ #
123
+ # @api private
124
+ # @param path [String] the endpoint path, which ends in the identifier or the username of the resource
125
+ # @param name [String] the identifier, or the username after an at sign, the message names the resource by
126
+ # @param client [Object] the client used to make the request
127
+ # @param params [Hash] query parameters merged over the default parameters; one that overrides a default field
128
+ # or expansion parameter to leave some out builds resources that are not hydrated, so hydrate fetches the rest
129
+ # @return [Resource] the resource
130
+ # @raise [MissingResource] if the API answers a 404 that reports the resource as not found, or a response that
131
+ # holds no resource
132
+ # @raise [X::NotFound] if the API answers any other 404
133
+ # @example Fetch a user that must exist
134
+ # X::User.__send__(:locate!, "users/7505382", "7505382", client: client)
135
+ def locate!(path, name, client:, **params)
136
+ problems = [] #: Array[Problem]
137
+ lookup(path, client:, **params) { |problem| problems << problem } || raise(missing(name, problems))
138
+ rescue X::NotFound => e
139
+ raise unless reports_missing?(e, path)
140
+
141
+ raise missing(name, e.problems)
142
+ end
143
+
144
+ # Fetch a list of resources from an endpoint without paginating
145
+ #
146
+ # Internal to x-resources: the batch lookups call it with the path of an endpoint, which names the API's own
147
+ # resources and can change within 1.x as the API does.
148
+ #
149
+ # @api private
150
+ # @param path [String] the endpoint path
151
+ # @param client [Object] the client used to make the request
152
+ # @param params [Hash] query parameters merged over the default parameters; one that overrides a default field
153
+ # or expansion parameter to leave some out builds resources that are not hydrated, so hydrate fetches the rest
154
+ # @return [Array<Resource>] the resources
155
+ # @yieldparam problem [Problem] each problem the API reported
156
+ # @example Fetch users by username
157
+ # X::User.__send__(:lookup_all, "users/by", client: client, usernames: ["sferik", "gem"])
158
+ def lookup_all(path, client:, **params, &)
159
+ query = Utils.merge_params(default_params, params)
160
+ collection_built_from(reporting(get(path, client:, query:), &), client:, hydrated: fully_requested_by?(query), query:)
161
+ end
162
+
163
+ # The client a lookup of this resource makes its requests with
164
+ #
165
+ # Most endpoints take the client as it is, and one that refuses the credentials a client signs with, such as the
166
+ # space endpoints, which refuse OAuth 1.0a, replaces it with a client that authenticates as the app.
167
+ #
168
+ # @api private
169
+ # @param client [Object] the client the lookup was given
170
+ # @return [Object] the client the request is made with
171
+ # @example Get the client a user lookup requests with
172
+ # X::User.__send__(:client_for, client) # => client
173
+ def client_for(client) = client
174
+
175
+ private :lookup, :locate, :locate!, :lookup_all, :client_for
176
+
177
+ private
178
+
179
+ # Request an endpoint
180
+ # @api private
181
+ # @param path [String] the endpoint path
182
+ # @param client [Object] the client used to make the request
183
+ # @param query [Hash] the query parameters, merged over the default parameters
184
+ # @return [Hash, nil] the parsed response body
185
+ def get(path, client:, query:)
186
+ client_for(client).get(Utils.path(path, query), **Utils::JSON_CLASSES)
187
+ end
188
+
189
+ # Build the resource a request that creates one returned, which must hold it
190
+ #
191
+ # The API answers a request that creates a resource with the resource, so a successful response without one
192
+ # created nothing the caller can read, and raises, holding the problems the response reported, as current!
193
+ # raises for a users/me that returns no user, rather than return nil, which the caller would read as the
194
+ # resource. A response whose data holds no identifier holds no resource, as find reads it, so it raises too.
195
+ #
196
+ # @api private
197
+ # @param body [Hash, nil] the parsed response body
198
+ # @param request [String] the method and path of the request, which the message names
199
+ # @param client [Object] the client used to make the request
200
+ # @return [Resource] the resource
201
+ # @raise [MissingResource] if the response holds no resource, or data without an identifier
202
+ # @raise [InvalidAttribute] if the response holds a resource with an identifier that is not one
203
+ # @example Build the post a request created
204
+ # X::Post.__send__(:created_from_response, {"data" => {"id" => "1"}}, "POST tweets", client: client)
205
+ def created_from_response(body, request, client:)
206
+ raise MissingResource.new("#{request} returned no #{self}", problems: Problem.all_from(body)) if resourceless?(body)
207
+
208
+ resource_built_from(body, client:, hydrated: false, query: nil) #: Resource
209
+ end
210
+
211
+ # The error of a resource a lookup did not find
212
+ # @api private
213
+ # @param name [String] the identifier, or the username after an at sign, the message names the resource by
214
+ # @param problems [Array<Problem>] the problems the API reported
215
+ # @return [MissingResource] the error, which names the class and the resource
216
+ def missing(name, problems) = MissingResource.new("Could not find #{self} #{name}", problems:)
217
+
218
+ # Whether a 404 reports the resource a lookup named as not found
219
+ #
220
+ # It does when it answers the lookup itself, a GET of a URI whose path ends in the path of the lookup, behind
221
+ # whatever path the base URL of the client holds, and its body describes, or names among its errors, a problem
222
+ # of the resource-not-found type. A NotFound that names no request, as one built without a URI does, is not
223
+ # known to answer the lookup, so it reports nothing.
224
+ #
225
+ # @api private
226
+ # @param error [X::NotFound] the error of the 404
227
+ # @param path [String] the endpoint path of the lookup
228
+ # @return [Boolean] true if the 404 answers the lookup and reports a resource as not found
229
+ def reports_missing?(error, path)
230
+ error.http_method.eql?(:get) && error.uri&.path.to_s.end_with?("/#{path}") &&
231
+ [*error.problems, error.problem].compact.any?(&:not_found?)
232
+ end
233
+
234
+ # Whether a response body holds no resource
235
+ #
236
+ # It holds none when it holds no data, data that is no object, or an object with no identifier.
237
+ #
238
+ # @api private
239
+ # @param body [Hash, nil] the parsed response body
240
+ # @return [Boolean] true if the body holds no object with an identifier as its data
241
+ def resourceless?(body) = Hash.try_convert(body.to_h["data"]).to_h[id_key].nil?
242
+
243
+ # Pass the problems a response body reports to a block, if there is one
244
+ # @api private
245
+ # @param body [Hash, nil] the parsed response body
246
+ # @return [Hash, nil] the body
247
+ # @yieldparam problem [Problem] each problem the body reports
248
+ def reporting(body)
249
+ Problem.all_from(body).each { |problem| yield problem } if block_given?
250
+ body
251
+ end
252
+ end
253
+ private_constant :Finders
254
+ end
255
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module X
4
+ module Resources
5
+ # Equality and hashing by class and identifier, so the same resource fetched twice compares equal
6
+ #
7
+ # Internal to x-resources: the methods it gives a resource, such as ==, are public API, but the module is only how
8
+ # they are shared, and which classes extend or include it can change within 1.x.
9
+ #
10
+ # @api semipublic
11
+ module Identity
12
+ # Compare resources by class and identifier
13
+ #
14
+ # @api public
15
+ # @param other [Object] the object to compare with
16
+ # @return [Boolean] true if the other object is the same kind of resource with the same identifier
17
+ # @example Compare users fetched in different requests
18
+ # post.author == client.find_user("sferik")
19
+ def ==(other)
20
+ self.class.equal?(other.class) && id.eql?(other.id)
21
+ end
22
+
23
+ # @!method eql?(other)
24
+ # Alias for ==, compares resources by class and identifier
25
+ # @api public
26
+ # @param other [Object] the object to compare with
27
+ # @return [Boolean] true if the other object is the same kind of resource with the same identifier
28
+ # @example Deduplicate resources
29
+ # [user, client.find_user("sferik")].uniq
30
+ alias_method :eql?, :==
31
+
32
+ # Hash resources by class and identifier
33
+ #
34
+ # @api public
35
+ # @return [Integer] the hash code
36
+ # @example Use resources as hash keys
37
+ # {user => 1}[client.find_user("sferik")]
38
+ def hash
39
+ [self.class, id].hash
40
+ end
41
+
42
+ # Deconstruct the resource into its attributes, so it matches a hash pattern
43
+ #
44
+ # Every attribute the resource declares is read as its own method reads it, so a pattern sees the
45
+ # identifier as a number, a timestamp as a Time, and a metric by the name it is read by. A pattern can ask
46
+ # for an attribute by another name it is read by, such as retweet_count for repost_count, and a pattern
47
+ # that asks for every attribute, with a double splat, gets each once, by the name the resource declares.
48
+ #
49
+ # @api public
50
+ # @param keys [Array<Symbol>, nil] the keys the pattern asks for, or nil for every attribute
51
+ # @return [Hash{Symbol => Object}] the attributes
52
+ # @example Match a post by its author
53
+ # puts "by sferik" if post in {author_id: 7505382}
54
+ # @example Match a post by a name from before posts were posts
55
+ # puts "widely reposted" if post in {retweet_count: 100..}
56
+ def deconstruct_keys(keys)
57
+ klass = self.class
58
+ names = klass.__send__(:attribute_names)
59
+ names = (names + klass.__send__(:attribute_aliases)) & keys unless keys.nil?
60
+ names.to_h { |name| [name, public_send(name)] }
61
+ end
62
+ end
63
+ private_constant :Identity
64
+ end
65
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require_relative "utils"
5
+
6
+ module X
7
+ module Resources
8
+ # The context of one API response: its identity map of expanded objects and stubs, and the problems it reported
9
+ # @api private
10
+ class Includes
11
+ # The keys the API gave the includes of a class before it named tweets posts, which it still gives them where it
12
+ # has not renamed them, such as in a stream
13
+ TWEET_KEYS = {"posts" => "tweets"}.freeze
14
+ private_constant :TWEET_KEYS
15
+
16
+ # Initialize a new identity map
17
+ #
18
+ # @api private
19
+ # @param data [Hash, nil] the includes hash from an API response
20
+ # @param problems [Array<Problem>] the problems the response reported
21
+ # @param query [Hash{String => Object}, nil] the query parameters of the request, merged over the defaults, or
22
+ # nil if they are not known
23
+ # @return [Includes] a new identity map
24
+ def initialize(data = nil, problems: [], query: nil)
25
+ @data = Utils.deep_freeze(data.to_h)
26
+ @problems = problems.freeze
27
+ @query = Utils.deep_freeze(query)
28
+ @monitor = Monitor.new
29
+ @index = {}
30
+ @resources = {}
31
+ freeze
32
+ end
33
+
34
+ # The problems the response reported, such as missing expanded resources
35
+ # @api private
36
+ # @return [Array<Problem>] the problems
37
+ attr_reader :problems
38
+
39
+ # What some resources built over the identity map refer to of it, as plain data
40
+ #
41
+ # A resource that Marshal writes holds it, and a page holds it once for the resources that share it. It is the
42
+ # included objects the resources refer to, and the ones those refer to in turn, so that every reference that
43
+ # resolves to an included object still does, with none of the rest of the response; the problems about any of
44
+ # them, or about none; and the query, which tells whether an included object is hydrated. The problems are
45
+ # written as themselves, which Marshal writes as their attributes.
46
+ #
47
+ # @api private
48
+ # @param resources [Array<Resource>] the resources, each built over this identity map
49
+ # @return [Array(Hash, Array<Problem>, Hash, nil)] the included objects, the problems, and the query
50
+ def state_of(resources)
51
+ kept = {} #: Hash[String, Array[attrs]]
52
+ ids = resources.map(&:id)
53
+ pending = resources.map { |resource| [resource.class, resource.attrs] } #: Array[[singleton(Resource), attrs]]
54
+ # Each object kept joins the objects whose references are read, which each reaches once it comes to them
55
+ pending.each do |klass, attrs|
56
+ referenced = referenced_by(klass, attrs)
57
+ ids.concat(referenced)
58
+ pending.concat(keep(kept, referenced))
59
+ end
60
+ [kept, problems_about(ids), @query]
61
+ end
62
+
63
+ # Check whether a resource of the response is hydrated as it is read back
64
+ #
65
+ # A resource written with the query of its request is hydrated only if that query asks for every field and
66
+ # expansion this release requests of its class, since a minor release may add to them, so that a resource an
67
+ # earlier release wrote as hydrated is not, once it lacks what was added, and hydrate fetches it. One written
68
+ # without a query, as one a caller built from a response is, is hydrated as it was written.
69
+ #
70
+ # @api private
71
+ # @param klass [Class] the resource class
72
+ # @param hydrated [Boolean] whether the resource was hydrated as it was written
73
+ # @return [Boolean] true if the resource holds every field this release requests
74
+ def hydrated_as_read?(klass, hydrated)
75
+ query = @query
76
+ hydrated && (query.nil? || klass.__send__(:fully_requested_by?, query))
77
+ end
78
+
79
+ # The problems the response reported about any of some identifiers
80
+ #
81
+ # A problem that names no resource is about any of them, too. A problem names the resource it is about by its resource_id, or by its value, and one that names neither
82
+ # could be about any resource of the response.
83
+ #
84
+ # @api private
85
+ # @param ids [Array<Object>] the identifiers of a resource and of the resources it refers to
86
+ # @return [Array<Problem>] the problems, frozen
87
+ def problems_about(ids)
88
+ ids = ids.map(&:to_s)
89
+ problems.select { |problem| about?(problem, ids) }.freeze
90
+ end
91
+
92
+ # Resolve a reference to the included resource or a stub holding its identifier
93
+ #
94
+ # Every reference to the same resource within one response resolves to the same object,
95
+ # so hydrating it once hydrates it everywhere it is referenced.
96
+ #
97
+ # An included resource is hydrated when the request asked for every field of its class, and its class expands
98
+ # nothing of its own, as a poll, a place, and media expand nothing, since the API applies the expansions of a
99
+ # request to its data alone: a post or a user included in a response lacks the resources it would expand, which
100
+ # hydrate looks up. A stub, of a resource the response did not include, is never hydrated.
101
+ #
102
+ # @api private
103
+ # @param klass [Class] the resource class
104
+ # @param id [String] the identifier
105
+ # @param client [Object, nil] the client used to fetch the response
106
+ # @return [Resource] the resource
107
+ def resolve(klass, id, client:)
108
+ @monitor.synchronize do
109
+ @resources[[klass, id]] ||= build(klass, id, client)
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ # Build the included resource an identifier names, or a stub of it
116
+ # @api private
117
+ # @param klass [Class] the resource class
118
+ # @param id [String] the identifier
119
+ # @param client [Object, nil] the client used to fetch the response
120
+ # @return [Resource] the resource
121
+ def build(klass, id, client)
122
+ attrs = index(klass)[id]
123
+ return klass.__send__(:build, {klass.__send__(:id_key) => id}, client:, includes: self) if attrs.nil?
124
+
125
+ klass.__send__(:build, attrs, client:, includes: self, hydrated: fully_requested?(klass))
126
+ end
127
+
128
+ # Check whether a problem names one of some identifiers, or names none
129
+ # @api private
130
+ # @param problem [Problem] the problem
131
+ # @param ids [Array<String>] the identifiers
132
+ # @return [Boolean] true if the problem names one of the identifiers, or names no resource
133
+ def about?(problem, ids)
134
+ named = [problem.resource_id, problem.value].compact
135
+ named.empty? || named.any? { |value| ids.include?(value.to_s) }
136
+ end
137
+
138
+ # Check whether the request asked for every field of a class that expands nothing
139
+ # @api private
140
+ # @param klass [Class] the resource class
141
+ # @return [Boolean] true if a resource of the class that the response included holds every field
142
+ def fully_requested?(klass)
143
+ query = @query
144
+ return false if query.nil? || klass.default_params.key?("expansions")
145
+
146
+ klass.__send__(:fully_requested_by?, query)
147
+ end
148
+
149
+ # Keep the included objects some identifiers name that are not kept yet
150
+ #
151
+ # An object is kept under the key the response included it by, in the order the response included it, so that
152
+ # what is kept resolves as the response did.
153
+ #
154
+ # @api private
155
+ # @param kept [Hash{String => Array<Hash>}] the included objects kept so far, which this adds to
156
+ # @param ids [Array<Object>] the identifiers, as the objects that refer to them hold them
157
+ # @return [Array(Class, Hash)] the class and attributes of each object this kept, whose references are kept next
158
+ def keep(kept, ids)
159
+ collections.flat_map do |klass, key|
160
+ found = named(klass, key, ids) - kept[key].to_a
161
+ kept[key] = @data.fetch(key) & (kept[key].to_a + found) unless found.empty?
162
+ found.map { |attrs| [klass, attrs] }
163
+ end
164
+ end
165
+
166
+ # The included objects of a resource class that some identifiers name
167
+ # @api private
168
+ # @param klass [Class] the resource class
169
+ # @param key [String] the key the response included the objects of the class under
170
+ # @param ids [Array<Object>] the identifiers
171
+ # @return [Array<Hash>] the objects, in the order the response included them
172
+ def named(klass, key, ids) = @data.fetch(key).select { |attrs| ids.include?(attrs[klass.__send__(:id_key)]) }
173
+
174
+ # The identifiers of what an object refers to, as the object holds them
175
+ #
176
+ # A reference resolves an identifier as the object holds it, so it is kept as that too.
177
+ #
178
+ # @api private
179
+ # @param klass [Class] the resource class of the object
180
+ # @param attrs [Hash{String => Object}] the attributes of the object
181
+ # @return [Array<Object>] the identifiers
182
+ def referenced_by(klass, attrs) = klass.__send__(:referenced_ids, attrs).compact
183
+
184
+ # The key the response included each resource class under, of those it included
185
+ # @api private
186
+ # @return [Array<Array(Class, String)>] each class, and the key the response included it under
187
+ def collections
188
+ classes = Resource.subclasses #: Array[singleton(Resource)]
189
+ classes.filter_map do |klass|
190
+ name = klass.__send__(:includes_key)
191
+ key = [name, TWEET_KEYS[name]].find { |candidate| @data.key?(candidate) } #: String?
192
+ [klass, key] unless key.nil?
193
+ end
194
+ end
195
+
196
+ # The included objects of one resource class, under the key the API gave them
197
+ # @api private
198
+ # @param klass [Class] the resource class
199
+ # @return [Array<Hash>] the included objects, empty if the response included none
200
+ def entries_of(klass)
201
+ key = klass.__send__(:includes_key)
202
+ @data.fetch(key) { @data.fetch(TWEET_KEYS[key], []) }
203
+ end
204
+
205
+ # Build or fetch the identifier index for one type of expanded object
206
+ #
207
+ # @api private
208
+ # @param klass [Class] the resource class
209
+ # @return [Hash{String => Hash}] the expanded objects keyed by identifier
210
+ def index(klass)
211
+ @index[klass.__send__(:includes_key)] ||= entries_of(klass).group_by { |attrs| attrs[klass.__send__(:id_key)] }.transform_values(&:first)
212
+ end
213
+ end
214
+ private_constant :Includes
215
+ end
216
+ end