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