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,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "memo"
4
+
5
+ module X
6
+ module Resources
7
+ # A group of stubs, no more than one lookup takes, that hydrate together in one request rather than one each
8
+ # @api private
9
+ class Batch
10
+ # Initialize a batch over some identifiers
11
+ #
12
+ # @api private
13
+ # @param klass [Class] the class of the resources, which BatchFinders extends
14
+ # @param ids [Array<String, Integer>] the identifiers
15
+ # @param client [Object] the client used to make the requests
16
+ # @return [Batch] a new batch
17
+ def initialize(klass, ids, client:)
18
+ @klass = klass
19
+ @ids = ids
20
+ @client = client
21
+ @memo = Memo.new
22
+ freeze
23
+ end
24
+
25
+ # The resource of one identifier, looking up every identifier of the batch at once
26
+ #
27
+ # @api private
28
+ # @param id [String, Integer] the identifier
29
+ # @return [Resource, nil] the resource, or nil if it was not found
30
+ def fetch(id) = resources[id]
31
+
32
+ private
33
+
34
+ # The resources of the batch, keyed by identifier
35
+ # @api private
36
+ # @return [Hash{Object => Resource}] the resources
37
+ def resources
38
+ @memo.fetch { @klass.find_all(@ids, client: @client).to_h { |resource| [resource.id, resource] } }
39
+ end
40
+ end
41
+ private_constant :Batch
42
+ end
43
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "finders"
4
+ require_relative "parallel"
5
+ require_relative "utils"
6
+
7
+ module X
8
+ module Resources
9
+ # Class methods that look resources up many at a time, extended into each resource class the API can look up that way
10
+ #
11
+ # A resource the API looks up only one at a time, such as a list, a community, or a direct message event, extends
12
+ # Finders alone, and so answers neither find_all nor hydrate_all, rather than answer them only to raise.
13
+ #
14
+ # Internal to x-resources: the methods it gives a resource class, such as X::Post.find_all, are public API, but the
15
+ # module is only how they are shared, and which classes extend or include it can change within 1.x.
16
+ #
17
+ # @api semipublic
18
+ module BatchFinders
19
+ include Finders
20
+
21
+ # Maximum number of identifiers accepted by a batch lookup endpoint
22
+ # @api private
23
+ MAX_BATCH_SIZE = 100
24
+
25
+ # Default number of batch lookups a request makes at once, which matches the chunks an upload sends at once
26
+ # @api private
27
+ DEFAULT_CONCURRENCY = 4
28
+
29
+ # The message of the error raised for a concurrency that would look nothing up
30
+ # @api private
31
+ INVALID_CONCURRENCY = "concurrency must be an Integer of at least 1, not %s"
32
+ private_constant :INVALID_CONCURRENCY
33
+
34
+ # The message of the error raised for resources to hydrate that are not of the class hydrating them
35
+ # @api private
36
+ FOREIGN_RESOURCE = "%s.hydrate_all hydrates %s resources, not %s"
37
+ private_constant :FOREIGN_RESOURCE
38
+
39
+ # Replace the resources that are not hydrated with the full resources
40
+ #
41
+ # A resource that hydrate would look up is looked up, which is a stub and also a resource a response included
42
+ # without every field, so what comes back is hydrated throughout. A resource that was not found is dropped, as
43
+ # is nil, which a reference to no resource reads as, such as the author of a post whose response named none,
44
+ # and a hydrated resource is kept as it is, so resources that are all hydrated need no lookup. What was found is
45
+ # stored in each original, so hydrating one of them afterwards costs no request, unless params override a
46
+ # default field or expansion parameter: what such a lookup found is not the full resource, so it is returned
47
+ # without being stored, and hydrating an original fetches the full resource. A resource that holds what hydrate
48
+ # returns, because hydrate or an earlier hydrate_all stored it, is replaced with that, and looked up again by no
49
+ # request, since the API bills each resource a lookup returns.
50
+ #
51
+ # @api public
52
+ # @param resources [Array<Resource, nil>] the resources, some of which may not be hydrated, and some nil
53
+ # @param client [Object] the client used to make the requests
54
+ # @param concurrency [Integer] the number of batch lookups made at once, four by default, which must be at
55
+ # least one
56
+ # @param params [Hash] query parameters merged over the default parameters; one that overrides a default field
57
+ # or expansion parameter to leave some out builds resources that are not hydrated, so hydrate fetches the rest
58
+ # @return [Array<Resource>] the resources, in order, with the ones that were not hydrated replaced, frozen
59
+ # @raise [ArgumentError] if the concurrency is less than one
60
+ # @raise [ArgumentError] if a resource is not of this class, which a lookup of its identifier would find
61
+ # another resource for, before a request
62
+ # @yieldparam problem [Problem] each problem the API reported, such as a resource that was not found
63
+ # @example Look up every field of the authors of posts, the ones a search included among them
64
+ # X::User.hydrate_all(posts.map(&:author), client: client)
65
+ # @example Expand only the authors a search did not include, which the API bills for alone
66
+ # X::User.hydrate_all(posts.filter_map(&:author).select(&:stub?), client: client)
67
+ def hydrate_all(resources, client:, concurrency: DEFAULT_CONCURRENCY, **params, &)
68
+ resources = resources.compact
69
+ validate_class!(resources)
70
+ partial = resources.reject { |resource| settled?(resource) }
71
+ replace = replacer(find_all(partial, client:, concurrency:, **params, &), params)
72
+ resources.filter_map { |resource| settled?(resource) ? resource.hydrate : replace.call(resource) }.freeze
73
+ end
74
+
75
+ # Look up many resources by identifier, in parallel batches
76
+ #
77
+ # The resources come back in the order of the identifiers they were asked for by, whatever order the batches
78
+ # were answered in, one for each identifier that was found, so an identifier asked for twice comes back twice,
79
+ # as hydrate_all keeps a resource it is given twice. Each identifier is asked for once, however often it is
80
+ # given.
81
+ #
82
+ # @api public
83
+ # @param ids [Array<String, Integer, Resource>] the identifiers, or resources of this class
84
+ # @param client [Object] the client used to make the requests
85
+ # @param concurrency [Integer] the number of batches looked up at once, four by default, which must be at least
86
+ # one; each is a request of up to 100 identifiers, so a lower number spends a rate limit more slowly
87
+ # @param params [Hash] query parameters merged over the default parameters; one that overrides a default field
88
+ # or expansion parameter to leave some out builds resources that are not hydrated, so hydrate fetches the rest
89
+ # @return [Array<Resource>] the resources that were found, one for each identifier that names one, frozen
90
+ # @raise [ArgumentError] if the concurrency is less than one
91
+ # @raise [ArgumentError] if an identifier is not one, or is a resource of another class, before a request
92
+ # @yieldparam problem [Problem] each problem the API reported, such as an identifier that was not found
93
+ # @example Look up many posts by identifier, reporting the ones that were not found
94
+ # X::Post.find_all([1234567890, 1234567891], client: client) { |problem| warn problem.detail }
95
+ # @example Look up many posts one batch at a time, to spend a rate limit more slowly
96
+ # X::Post.find_all(ids, client: client, concurrency: 1)
97
+ def find_all(ids, client:, concurrency: DEFAULT_CONCURRENCY, **params, &)
98
+ ids = ids.map { |id| Utils.id_of(id, self) }
99
+ in_order_of(lookup_in_batches(endpoint!, batch_key, ids, client:, concurrency:, **params, &), ids)
100
+ end
101
+
102
+ private
103
+
104
+ # What replaces each resource that is not hydrated, from what a lookup found
105
+ #
106
+ # A lookup of every field found the full resource, which is stored in the original, as is a resource it did not
107
+ # find. A lookup of some fields found what is returned in its place, and nothing is stored.
108
+ #
109
+ # @api private
110
+ # @param found [Array<Resource>] the resources the lookup found
111
+ # @param params [Hash] the query parameters the lookup was given, merged over the default parameters
112
+ # @return [Proc] a callable passed a resource that is not hydrated, which returns what replaces it, or nil
113
+ def replacer(found, params)
114
+ by_id = found.to_h { |resource| [resource.id, resource] }
115
+ return ->(resource) { by_id[resource.id] } unless fully_requested_by?(Utils.merge_params(default_params, params))
116
+
117
+ ->(resource) { resource.__send__(:hydrated_with, by_id[resource.id]) }
118
+ end
119
+
120
+ # Look up values in parallel batches, asking for each value once
121
+ # @api private
122
+ # @param path [String] the batch lookup endpoint path
123
+ # @param key [Symbol] the query parameter the values go in, such as ids or usernames
124
+ # @param values [Array<String>] the values
125
+ # @param client [Object] the client used to make the requests
126
+ # @param concurrency [Integer] the number of batches looked up at once
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 [Array<Resource>] the resources that were found, in the order the batches were answered in
130
+ # @raise [ArgumentError] if the concurrency is less than one
131
+ # @yieldparam problem [Problem] each problem the responses reported
132
+ def lookup_in_batches(path, key, values, client:, concurrency:, **params, &)
133
+ validate_concurrency!(concurrency)
134
+ query = Utils.merge_params(default_params, params)
135
+ bodies = Parallel.map(values.uniq.each_slice(MAX_BATCH_SIZE), concurrency:) { |batch| get(path, client:, query: query.merge(Utils.query(key => batch))) }
136
+ bodies.flat_map { |body| collection_built_from(reporting(body, &), client:, hydrated: fully_requested_by?(query), query:) }
137
+ end
138
+
139
+ # Order resources as the identifiers that name them, one for each identifier
140
+ #
141
+ # An identifier is read as the resource reads its own, so that one asked for as a String of digits matches the
142
+ # Integer of the resource.
143
+ #
144
+ # @api private
145
+ # @param resources [Array<Resource>] the resources found
146
+ # @param ids [Array<String>] the identifiers asked for
147
+ # @return [Array<Resource>] the resource each identifier names, in the order of the identifiers, frozen
148
+ def in_order_of(resources, ids)
149
+ convert = Attributes::CONVERTERS.fetch(id_type)
150
+ by_id = resources.to_h { |resource| [resource.id, resource] }
151
+ ids.filter_map { |id| by_id[convert.call(id)] }.freeze
152
+ end
153
+
154
+ # Check whether hydrating a resource costs no request
155
+ # @api private
156
+ # @param resource [Resource] the resource
157
+ # @return [Boolean] true if the resource is hydrated, or holds what hydrate returns
158
+ def settled?(resource) = resource.hydrated? || resource.__send__(:hydration_stored?)
159
+
160
+ # Check that a number of batches to look up at once is an Integer of at least one
161
+ #
162
+ # Anything that is not an Integer, such as a String read from an environment variable, raises ArgumentError too,
163
+ # rather than NoMethodError from the check.
164
+ #
165
+ # @api private
166
+ # @param concurrency [Integer] the number of batches looked up at once
167
+ # @return [void]
168
+ # @raise [ArgumentError] if the concurrency is not an Integer, or is less than one
169
+ def validate_concurrency!(concurrency)
170
+ raise ArgumentError, format(INVALID_CONCURRENCY, concurrency.inspect) unless concurrency.instance_of?(Integer) && concurrency.positive?
171
+ end
172
+
173
+ # Check that resources to hydrate are of this class, whose endpoint looks them up
174
+ # @api private
175
+ # @param resources [Array<Resource>] the resources
176
+ # @return [void]
177
+ # @raise [ArgumentError] if a resource is not of this class
178
+ def validate_class!(resources)
179
+ foreign = resources.grep_v(self).first
180
+ raise ArgumentError, format(FOREIGN_RESOURCE, self, self, foreign.class) unless foreign.nil?
181
+ end
182
+ end
183
+ private_constant :BatchFinders
184
+ end
185
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module X
6
+ # A folder the authenticated user keeps bookmarks in
7
+ #
8
+ # The API offers no lookup of a folder, so a folder is read from the bookmark folders of the user it belongs to,
9
+ # which X::User#bookmark_folders pages through, and the class answers no finder. from_id builds one from its
10
+ # identifier, which X::User#bookmarks takes as the folder to read the posts of, but hydrate and refresh raise
11
+ # UnsupportedOperation for one that is not hydrated, since there is nothing to look it up with.
12
+ #
13
+ # @api public
14
+ class BookmarkFolder < Resource
15
+ # @!attribute [r] name
16
+ # The name of the folder
17
+ # @api public
18
+ # @return [String, nil] the name
19
+ # @example Get the name
20
+ # folder.name # => "Ruby"
21
+ attribute :name
22
+ end
23
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "cursor"
5
+ require_relative "finders"
6
+ require_relative "resource"
7
+
8
+ module X
9
+ module Resources
10
+ # A community of users who post to one another
11
+ # @api public
12
+ class ::X::Community < Resource
13
+ extend Finders
14
+
15
+ # The mixins of the class by their full names, which YARD needs to resolve them in a class opened under X
16
+ #
17
+ # @!parse
18
+ # extend X::Resources::Finders
19
+
20
+ # Every public community field
21
+ #
22
+ # A minor release may add to it the fields the API adds, so that a lookup asks for them too; see
23
+ # {Resource#hydrated?} for what that means for a resource looked up with a list of fields of its own.
24
+ FIELDS = %w[access created_at description id join_policy member_count name].freeze
25
+ # Maximum number of communities per page of a search
26
+ # @api private
27
+ MAX_RESULTS = 100
28
+ private_constant :MAX_RESULTS
29
+
30
+ class << self
31
+ # The API endpoint used to look up communities by identifier
32
+ #
33
+ # @api private
34
+ # @return [String] the endpoint
35
+ # @example Get the endpoint
36
+ # X::Community.__send__(:endpoint) # => "communities"
37
+ def endpoint = "communities"
38
+
39
+ # The query parameter that selects community fields
40
+ #
41
+ # @api private
42
+ # @return [String] the fields parameter
43
+ # @example Get the fields parameter
44
+ # X::Community.__send__(:fields_key) # => "community.fields"
45
+ def fields_key = "community.fields"
46
+
47
+ private :endpoint, :fields_key
48
+
49
+ # The default query parameters requesting every community field
50
+ #
51
+ # @api public
52
+ # @return [Hash{String => Array<String>}] the default query parameters
53
+ # @example Get the default parameters
54
+ # X::Community.default_params["community.fields"]
55
+ def default_params = {"community.fields" => FIELDS}
56
+
57
+ # Search communities
58
+ #
59
+ # @api public
60
+ # @param query [String] the search query
61
+ # @param client [Object] the client used to make the requests
62
+ # @param params [Hash] query parameters merged over the default parameters
63
+ # @return [Cursor] a cursor over the matching communities
64
+ # @example Print the communities matching a query
65
+ # X::Community.search("ruby", client: client).each { |community| puts community.name }
66
+ def search(query, client:, **params)
67
+ Cursor.__send__(:build, self, "communities/search", client:, params: {query:, max_results: MAX_RESULTS}.merge(params),
68
+ token_param: "next_token", min_results: 10)
69
+ end
70
+ end
71
+
72
+ # @!attribute [r] name
73
+ # The name
74
+ # @api public
75
+ # @return [String, nil] the name
76
+ # @example Get the name
77
+ # community.name
78
+ attribute :name
79
+
80
+ # @!attribute [r] description
81
+ # The description
82
+ # @api public
83
+ # @return [String, nil] the description
84
+ # @example Get the description
85
+ # community.description
86
+ attribute :description
87
+
88
+ # @!attribute [r] created_at
89
+ # The creation time
90
+ # @api public
91
+ # @return [Time, nil] the creation time
92
+ # @example Get the creation time
93
+ # community.created_at
94
+ attribute :created_at, :time
95
+
96
+ # @!attribute [r] member_count
97
+ # The number of members
98
+ # @api public
99
+ # @return [Integer, nil] the member count
100
+ # @example Get the member count
101
+ # community.member_count
102
+ attribute :member_count, :integer
103
+
104
+ # @!attribute [r] access
105
+ # Who can see the community's posts, as the API names it
106
+ # @api public
107
+ # @return [String, nil] the access level
108
+ # @example Get the access level
109
+ # community.access
110
+ attribute :access
111
+
112
+ # @!attribute [r] join_policy
113
+ # How users become members, as the API names it
114
+ # @api public
115
+ # @return [String, nil] the join policy
116
+ # @example Get the join policy
117
+ # community.join_policy
118
+ attribute :join_policy
119
+
120
+ # The permalink of the community
121
+ #
122
+ # @api public
123
+ # @return [String] the x.com address of the community
124
+ # @example Get the permalink
125
+ # community.permalink # => "https://x.com/i/communities/1234567890"
126
+ def permalink = "https://x.com/i/communities/#{id}"
127
+
128
+ # The permalink of the community as a URI
129
+ #
130
+ # @api public
131
+ # @return [URI::Generic] the x.com address of the community
132
+ # @example Get the address as a URI
133
+ # community.uri # => #<URI::HTTPS https://x.com/i/communities/1234567890>
134
+ def uri = URI(permalink)
135
+ end
136
+ end
137
+ end