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,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "errors"
5
+ require_relative "shape"
6
+ require_relative "page_limit"
7
+ require_relative "utils"
8
+
9
+ module X
10
+ module Resources
11
+ # Counts of the posts that match a query, which the API bills by the request rather than by the post
12
+ #
13
+ # The counts endpoints refuse OAuth 1.0a, so a client that signs with it counts with a copy that authenticates as
14
+ # the app. A client signed in with OAuth 2.0 as a user that holds no credentials of the app counts as the user,
15
+ # which the count of recent posts takes, and the count of the full archive, which takes app-only authentication
16
+ # alone, refuses with 403 Forbidden, raising X::Forbidden.
17
+ #
18
+ # Internal to x-resources: the methods it gives Post, such as X::Post.count, are public API, but the module is only
19
+ # how they are shared, and which classes extend or include it can change within 1.x.
20
+ #
21
+ # @api semipublic
22
+ module PostCounts
23
+ # The endpoint that counts the posts from the last seven days
24
+ # @api private
25
+ RECENT_ENDPOINT = "tweets/counts/recent"
26
+ # The endpoint that counts the posts from the full archive
27
+ # @api private
28
+ ALL_ENDPOINT = "tweets/counts/all"
29
+ # The granularity that makes the fewest periods, and so the least data
30
+ # @api private
31
+ DEFAULT_GRANULARITY = "day"
32
+ private_constant :RECENT_ENDPOINT, :ALL_ENDPOINT, :DEFAULT_GRANULARITY
33
+
34
+ # Count the recent posts that match a query, without reading them
35
+ #
36
+ # The recent posts are counted a page of periods at a time, as the full archive is, which max_pages limits.
37
+ #
38
+ # @api public
39
+ # @param query [String] the search query
40
+ # @param client [Object] the client used to make the requests
41
+ # @param max_pages [Integer, nil] the most pages of counts to request, or nil for no limit
42
+ # @param params [Hash] query parameters, such as start_time and end_time
43
+ # @return [Integer] the number of matching posts
44
+ # @raise [ArgumentError] if max_pages is neither an Integer of at least 1 nor nil, before a request
45
+ # @raise [InvalidAttribute] if the response holds a total that is not a number
46
+ # @raise [UnreadableResponse] if a page names the token of a page before it as the next
47
+ # @raise [PageLimitReached] if the count requests max_pages pages, and the API names another
48
+ # @example Count the recent posts about Ruby
49
+ # X::Post.count("ruby", client: client)
50
+ def count(query, client:, max_pages: nil, **params) = total(pages(RECENT_ENDPOINT, query, client:, max_pages:, **params))
51
+
52
+ # Count the posts from the full archive that match a query, without reading them
53
+ #
54
+ # The archive is counted a page of periods at a time, and the API bills each page, so a count over many years
55
+ # can take many requests, which max_pages limits, raising PageLimitReached rather than read past them.
56
+ #
57
+ # @api public
58
+ # @param query [String] the search query
59
+ # @param client [Object] the client used to make the requests
60
+ # @param max_pages [Integer, nil] the most pages of counts to request, or nil for no limit
61
+ # @param params [Hash] query parameters, such as start_time and end_time
62
+ # @return [Integer] the number of matching posts
63
+ # @raise [ArgumentError] if max_pages is neither an Integer of at least 1 nor nil, before a request
64
+ # @raise [InvalidAttribute] if the response holds a total that is not a number
65
+ # @raise [UnreadableResponse] if a page names the token of a page before it as the next
66
+ # @raise [PageLimitReached] if the count requests max_pages pages, and the API names another
67
+ # @example Count every post about Ruby
68
+ # X::Post.count_all("ruby", client: client)
69
+ # @example Count the posts about Ruby in no more than three requests
70
+ # X::Post.count_all("ruby", client: client, max_pages: 3)
71
+ def count_all(query, client:, max_pages: nil, **params) = total(pages(ALL_ENDPOINT, query, client:, max_pages:, **params))
72
+
73
+ # Count the posts from the last seven days that match a query, by period
74
+ #
75
+ # The recent posts are counted a page of periods at a time, as the full archive is, which max_pages limits.
76
+ #
77
+ # @api public
78
+ # @param query [String] the search query
79
+ # @param client [Object] the client used to make the requests
80
+ # @param max_pages [Integer, nil] the most pages of counts to request, or nil for no limit
81
+ # @param params [Hash] query parameters, such as granularity, which is day by default
82
+ # @return [Hash{Range<Time> => Integer}] the number of matching posts, keyed by the time each period spans, from
83
+ # its start up to, but not including, its end, oldest first
84
+ # @raise [ArgumentError] if max_pages is neither an Integer of at least 1 nor nil, before a request
85
+ # @raise [InvalidAttribute] if the response holds a period without a start and an end in ISO 8601, or without a
86
+ # count
87
+ # @raise [UnreadableResponse] if a page names the token of a page before it as the next
88
+ # @raise [PageLimitReached] if the count requests max_pages pages, and the API names another
89
+ # @example Count the recent posts about Ruby by hour
90
+ # X::Post.count_by_period("ruby", client: client, granularity: "hour")
91
+ def count_by_period(query, client:, max_pages: nil, **params) = periods(pages(RECENT_ENDPOINT, query, client:, max_pages:, **params))
92
+
93
+ # Count the posts from the full archive that match a query, by period
94
+ #
95
+ # The archive is counted a page of periods at a time, and the API bills each page, so a count over many years
96
+ # can take many requests, which max_pages limits, raising PageLimitReached rather than read past them.
97
+ #
98
+ # @api public
99
+ # @param query [String] the search query
100
+ # @param client [Object] the client used to make the requests
101
+ # @param max_pages [Integer, nil] the most pages of counts to request, or nil for no limit
102
+ # @param params [Hash] query parameters, such as granularity, which is day by default
103
+ # @return [Hash{Range<Time> => Integer}] the number of matching posts, keyed by the time each period spans, from
104
+ # its start up to, but not including, its end, oldest first
105
+ # @raise [ArgumentError] if max_pages is neither an Integer of at least 1 nor nil, before a request
106
+ # @raise [InvalidAttribute] if the response holds a period without a start and an end in ISO 8601, or without a
107
+ # count
108
+ # @raise [UnreadableResponse] if a page names the token of a page before it as the next
109
+ # @raise [PageLimitReached] if the count requests max_pages pages, and the API names another
110
+ # @example Count every post about Ruby by day
111
+ # X::Post.count_all_by_period("ruby", client: client)
112
+ def count_all_by_period(query, client:, max_pages: nil, **params) = periods(pages(ALL_ENDPOINT, query, client:, max_pages:, **params))
113
+
114
+ private
115
+
116
+ # Request every page of counts, following the next page token, up to a limit
117
+ # @api private
118
+ # @param path [String] the counts endpoint path
119
+ # @param query [String] the search query
120
+ # @param client [Object] the client used to make the requests
121
+ # @param max_pages [Integer, nil] the most pages to request, or nil for no limit
122
+ # @param params [Hash] query parameters
123
+ # @return [Array<Hash>] the response bodies
124
+ # @raise [ArgumentError] if max_pages is neither an Integer of at least 1 nor nil, before a request
125
+ # @raise [InvalidAttribute] if a response holds a meta that is not an object
126
+ # @raise [UnreadableResponse] if a response names the token of a page before it as the next
127
+ # @raise [PageLimitReached] if the pages requested reach max_pages, and the last names another
128
+ def pages(path, query, client:, max_pages:, **params)
129
+ params, max_pages = {query:, granularity: DEFAULT_GRANULARITY}.merge(params), PageLimit.check!(max_pages)
130
+ client = Utils.app_client(client)
131
+ bodies = [] #: Array[Hash[String, untyped]]
132
+ given = params[:next_token]
133
+ loop do
134
+ bodies << client.get(Utils.path(path, params), **Utils::JSON_CLASSES).to_h
135
+ token = next_token(bodies, given, max_pages) or break
136
+ params = params.merge(next_token: token)
137
+ end
138
+ bodies
139
+ end
140
+
141
+ # The token of the page of counts after the last, which fetched no page before it
142
+ #
143
+ # A page that names the token of a page before it as the next would have the pages requested again for good,
144
+ # and the API bill each request, so it raises instead. The next_token the counts were given, which fetched the
145
+ # first page, is the token of a page before each of them too. An empty token names no page, so the page that
146
+ # names one is the last, as a page that names none is. A page after as many as max_pages allows raises too,
147
+ # rather than be requested.
148
+ #
149
+ # @api private
150
+ # @param bodies [Array<Hash>] the response bodies so far
151
+ # @param given [String, nil] the next_token the counts were given, or nil for none
152
+ # @param max_pages [Integer, nil] the most pages to request, or nil for no limit
153
+ # @return [String, nil] the token, or nil if the last page is the last of the counts
154
+ # @raise [InvalidAttribute] if the last response holds a meta that is not an object
155
+ # @raise [UnreadableResponse] if the last response names the token of a page before it as the next
156
+ # @raise [PageLimitReached] if the pages requested reach max_pages, and the last names another
157
+ def next_token(bodies, given, max_pages)
158
+ tokens = bodies.map { |body| Shape.dig("The next page of the counts of #{self}", body, %w[meta next_token]) }
159
+ token = tokens.pop
160
+ return if token.nil? || token.eql?("")
161
+
162
+ raise UnreadableResponse, "The counts of #{self} name the next_token #{token.inspect}, which fetched an earlier page" if [given, *tokens].include?(token)
163
+
164
+ PageLimit.reached!("The counts of #{self}", read: bodies.size, max_pages:, next_token: token)
165
+ token
166
+ end
167
+
168
+ # The total count of every page, which the API names for tweets or for posts
169
+ # @api private
170
+ # @param bodies [Array<Hash>] the response bodies
171
+ # @return [Integer] the total
172
+ # @raise [InvalidAttribute] if a page holds a total that is not a number
173
+ def total(bodies) = bodies.sum { |body| page_total(body.dig("meta", "total_tweet_count") || body.dig("meta", "total_post_count")) }
174
+
175
+ # The total count of one page, which is zero when the page holds none
176
+ # @api private
177
+ # @param value [Integer, String, nil] the total the page holds
178
+ # @return [Integer] the total
179
+ # @raise [InvalidAttribute] if the total is not a number
180
+ def page_total(value) = Utils.read("The total of the counts of #{self}", value) { Shape.integer(value) } || 0
181
+
182
+ # The count of each period of every page, oldest first
183
+ #
184
+ # The API serves the newest page first while each page runs oldest to newest, so the periods of a paginated
185
+ # count are sorted before they are frozen, and the result reads in time order however many pages it took.
186
+ #
187
+ # @api private
188
+ # @param bodies [Array<Hash>] the response bodies
189
+ # @return [Hash{Range<Time> => Integer}] the counts, keyed by the time each period spans, in time order
190
+ # @raise [InvalidAttribute] if a period has no start and end in ISO 8601, or no count that is a number, or a
191
+ # response holds the periods as something other than a list of objects
192
+ def periods(bodies)
193
+ entries = bodies.flat_map { |body| Shape.objects("A period of the counts of #{self}", body["data"]) } #: Array[Hash[String, untyped]]
194
+ entries.map { |entry| period(entry) }.sort_by { |span, _count| span.begin }.to_h.freeze
195
+ end
196
+
197
+ # The time a period spans and the number of posts in it
198
+ #
199
+ # The period spans its start up to, but not including, its end, which is the start of the period after it. The
200
+ # API names the number for tweets or for posts.
201
+ #
202
+ # @api private
203
+ # @param entry [Hash] the period, with its start, its end, and its count
204
+ # @return [Array(Range<Time>, Integer)] the time it spans and the count
205
+ # @raise [InvalidAttribute] if the period has no start and end in ISO 8601, or no count that is an Integer that is
206
+ # not negative or a String of digits
207
+ def period(entry)
208
+ Utils.read("A period of the counts of #{self}", entry) do
209
+ [Time.iso8601(entry["start"].to_s)...Time.iso8601(entry["end"].to_s), Shape.integer(entry["tweet_count"] || entry["post_count"]) || raise(ArgumentError, "a period needs a count")]
210
+ end
211
+ end
212
+ end
213
+ private_constant :PostCounts
214
+ end
215
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cursor"
4
+ require_relative "utils"
5
+
6
+ module X
7
+ module Resources
8
+ # The searches of posts, and the posts of the authenticated user that others reposted
9
+ #
10
+ # Internal to x-resources: the methods it gives Post, such as X::Post.search, are public API, but the module is only
11
+ # how they are shared, and which classes extend or include it can change within 1.x.
12
+ #
13
+ # @api semipublic
14
+ module PostSearch
15
+ # Maximum number of posts per page of a search, or of the reposts of the authenticated user
16
+ # @api private
17
+ MAX_RESULTS = 100
18
+ # Maximum number of posts per page of full-archive search, which allows only MAX_RESULTS with context annotations
19
+ # @api private
20
+ MAX_ARCHIVE_RESULTS = 500
21
+ private_constant :MAX_RESULTS, :MAX_ARCHIVE_RESULTS
22
+
23
+ # Search recent posts
24
+ #
25
+ # @api public
26
+ # @param query [String] the search query
27
+ # @param client [Object] the client used to make the requests
28
+ # @param params [Hash] query parameters merged over the default parameters
29
+ # @return [Cursor] a cursor over the matching posts
30
+ # @example Print posts about Ruby
31
+ # X::Post.search("ruby -is:retweet", client: client).each { |post| puts post.text }
32
+ def search(query, client:, **params)
33
+ # @type self: singleton(Post)
34
+ Cursor.__send__(:build, self, "tweets/search/recent", client:, params: {query:, max_results: MAX_RESULTS}.merge(params), min_results: 10)
35
+ end
36
+
37
+ # Search the full archive of posts
38
+ #
39
+ # A page holds up to 500 posts, or 100 when the request asks for context annotations, as the default fields do.
40
+ #
41
+ # @api public
42
+ # @param query [String] the search query
43
+ # @param client [Object] the client used to make the requests
44
+ # @param params [Hash] query parameters merged over the default parameters
45
+ # @return [Cursor] a cursor over the matching posts
46
+ # @example Print every post about Ruby
47
+ # X::Post.search_all("ruby -is:retweet", client: client).each { |post| puts post.text }
48
+ # @example Page through every post about Ruby 500 at a time, without context annotations
49
+ # X::Post.search_all("ruby", client: client, "post.fields": %w[created_at text])
50
+ def search_all(query, client:, **params)
51
+ # @type self: singleton(Post)
52
+ max_results = context_annotations?(params) ? MAX_RESULTS : MAX_ARCHIVE_RESULTS
53
+ Cursor.__send__(:build, self, "tweets/search/all", client:, params: {query:, max_results:}.merge(params), min_results: 10)
54
+ end
55
+
56
+ # The posts of the authenticated user that other users have reposted
57
+ #
58
+ # The endpoint names no user, so these are always the posts of the user the client authenticates as.
59
+ #
60
+ # @api public
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 reposted posts
64
+ # @example Print the reposted posts
65
+ # X::Post.reposts_of_me(client: client).each { |post| puts post.text }
66
+ def reposts_of_me(client:, **params)
67
+ # @type self: singleton(Post)
68
+ Cursor.__send__(:build, self, "users/reposts_of_me", client:, params: {max_results: MAX_RESULTS}.merge(params))
69
+ end
70
+
71
+ alias_method :retweets_of_me, :reposts_of_me
72
+
73
+ private
74
+
75
+ # Check whether a request asks for the context annotations of its posts
76
+ # @api private
77
+ # @param params [Hash] query parameters merged over the default parameters
78
+ # @return [Boolean] true if the post fields include context_annotations
79
+ def context_annotations?(params)
80
+ # @type self: singleton(Post)
81
+ Utils.merge_params(default_params, params)["post.fields"].to_s.split(",").include?("context_annotations")
82
+ end
83
+ end
84
+ private_constant :PostSearch
85
+ end
86
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "serialization"
5
+ require_relative "shape"
6
+ require_relative "utils"
7
+ require_relative "value_equality"
8
+ require_relative "value_marshalling"
9
+
10
+ module X
11
+ module Resources
12
+ # How many posts the app's project has read, as the usage endpoint reports it
13
+ #
14
+ # The API caps the posts a project reads each month, and bills each post read, so the usage shows both what a
15
+ # project has spent and how much of its cap remains.
16
+ #
17
+ # @api public
18
+ class ::X::PostUsage
19
+ include Serialization
20
+ include ValueEquality
21
+ include ValueMarshalling
22
+
23
+ # The mixins of the class by their full names, which YARD needs to resolve them in a class opened under X
24
+ #
25
+ # @!parse
26
+ # include X::Resources::Serialization
27
+ # include X::Resources::ValueEquality
28
+ # include X::Resources::ValueMarshalling
29
+
30
+ # The endpoint that reports the post usage of the project
31
+ # @api private
32
+ ENDPOINT = "usage/tweets"
33
+ private_constant :ENDPOINT
34
+ # Every field of the usage
35
+ FIELDS = %w[cap_reset_day daily_client_app_usage daily_project_usage project_cap project_id project_usage].freeze
36
+
37
+ # The raw attributes of the usage
38
+ # @api public
39
+ # @return [Hash{String => Object}] the attributes
40
+ # @example Get the raw attributes
41
+ # usage.attrs # => {"project_usage" => "1234", "project_cap" => "3000000", ...}
42
+ attr_reader :attrs
43
+
44
+ # Look up the current post usage of the project the client's app belongs to
45
+ #
46
+ # A project has one usage, so it is looked up by no identifier, as X::User.current looks up the one user a
47
+ # client signs in as. The usage endpoint takes app-only authentication, so a client that signs its requests with OAuth 1.0a
48
+ # looks the usage up with a copy that authenticates as the app. A client signed in with OAuth 2.0 as a user that
49
+ # holds no credentials of the app requests as the user, which the endpoint refuses with X::Forbidden.
50
+ #
51
+ # A response that holds no usage returns nil, as X::User.current does for a users/me that holds no user, and
52
+ # passes the problems it reported to the block, if there is one.
53
+ #
54
+ # @api public
55
+ # @param client [Object] the client used to make the request
56
+ # @param params [Hash] query parameters, such as days, the number of days to report, which is 7 by default
57
+ # @return [PostUsage, nil] the usage, or nil if the response holds none
58
+ # @yieldparam problem [Problem] each problem the API reported
59
+ # @example Look up the usage of the last 30 days
60
+ # X::PostUsage.current(client: client, days: 30)&.project_usage
61
+ # @example Log why the API returned no usage
62
+ # X::PostUsage.current(client: client) { |problem| warn problem.detail }
63
+ def self.current(client:, **params)
64
+ body = Utils.app_client(client).get(Utils.path(ENDPOINT, {"usage.fields" => FIELDS}.merge(params)), **Utils::JSON_CLASSES)
65
+ Problem.all_from(body).each { |problem| yield problem } if block_given?
66
+ Hash.try_convert(body.to_h["data"])&.then { |data| new(data) }
67
+ end
68
+
69
+ # Look up the current post usage of the project, which must be returned
70
+ #
71
+ # @api public
72
+ # @param client [Object] the client used to make the request
73
+ # @param params [Hash] query parameters, such as days, the number of days to report, which is 7 by default
74
+ # @return [PostUsage] the usage
75
+ # @raise [MissingResource] if the API returns no usage
76
+ # @example Look up the usage of the last 30 days
77
+ # X::PostUsage.current!(client: client, days: 30).project_usage
78
+ def self.current!(client:, **params)
79
+ problems = [] #: Array[Problem]
80
+ current(client:, **params) { |problem| problems << problem } || raise(MissingResource.new("#{ENDPOINT} returned no usage", problems:))
81
+ end
82
+
83
+ # Initialize the usage from the attributes the API reported
84
+ #
85
+ # @api public
86
+ # @param attrs [Hash{String, Symbol => Object}] the attributes
87
+ # @return [PostUsage] a new usage
88
+ # @raise [ArgumentError] if the attributes are not a Hash
89
+ # @example Build a usage
90
+ # X::PostUsage.new({"project_usage" => "1234"})
91
+ def initialize(attrs)
92
+ @attrs = Utils.deep_freeze(Utils.attributes!(attrs))
93
+ freeze
94
+ end
95
+
96
+ # The identifier of the project
97
+ #
98
+ # @api public
99
+ # @return [Integer, nil] the identifier
100
+ # @example Get the project identifier
101
+ # usage.project_id # => 1234567890
102
+ def project_id = integer("project_id", attrs["project_id"])
103
+
104
+ # The number of posts the project has read in the current billing cycle
105
+ #
106
+ # @api public
107
+ # @return [Integer, nil] the number of posts
108
+ # @example Get the posts read this cycle
109
+ # usage.project_usage # => 1234
110
+ def project_usage = integer("project_usage", attrs["project_usage"])
111
+
112
+ # The number of posts the project may read in a billing cycle
113
+ #
114
+ # @api public
115
+ # @return [Integer, nil] the cap
116
+ # @example Get the cap
117
+ # usage.project_cap # => 3000000
118
+ def project_cap = integer("project_cap", attrs["project_cap"])
119
+
120
+ # The day of the month the billing cycle, and so the usage, starts over
121
+ #
122
+ # It is an Integer whether the response holds it as a number or as a String, as it holds the other counts.
123
+ #
124
+ # @api public
125
+ # @return [Integer, nil] the day of the month
126
+ # @raise [InvalidAttribute] if the response holds a value that is not a number
127
+ # @example Get the reset day
128
+ # usage.cap_reset_day # => 16
129
+ def cap_reset_day = integer("cap_reset_day", attrs["cap_reset_day"])
130
+
131
+ # The number of posts the project read each day
132
+ #
133
+ # @api public
134
+ # @return [Hash{Time => Integer}] the number of posts, keyed by the start of each day
135
+ # @raise [InvalidAttribute] if the response holds a day without a date in ISO 8601, or a number that is not one,
136
+ # or holds the days as something other than a list of objects
137
+ # @example Get the posts read yesterday
138
+ # usage.daily.values.last(2).first
139
+ def daily = days("daily", Shape.dig("#{self.class}#daily", attrs, %w[daily_project_usage usage]))
140
+
141
+ # The number of posts each of the project's apps read each day
142
+ #
143
+ # @api public
144
+ # @return [Hash{Integer, nil => Hash{Time => Integer}}] the daily usage, keyed by the identifier of each app
145
+ # @raise [InvalidAttribute] if the response holds an app identifier that is not a number, a day without a date in
146
+ # ISO 8601, or a number that is not one, or holds the apps or their days as something other than a list of objects
147
+ # @example Total the posts each app read
148
+ # usage.daily_by_app.transform_values { |days| days.values.sum }
149
+ def daily_by_app
150
+ by_app = {} #: Hash[Integer?, Hash[Time, Integer]]
151
+ Shape.objects("#{self.class}#daily_by_app", attrs["daily_client_app_usage"]).each do |app|
152
+ by_app[integer("daily_by_app", app["client_app_id"])] = days("daily_by_app", app["usage"])
153
+ end
154
+ by_app.freeze
155
+ end
156
+
157
+ # Deconstruct the usage into what its readers read, so it matches a hash pattern
158
+ #
159
+ # Only the readers a pattern names are read, so one that names the counts of the project matches a usage whose
160
+ # days cannot be read.
161
+ #
162
+ # @api public
163
+ # @param keys [Array<Symbol>, nil] the keys the pattern asks for, or nil for every reader
164
+ # @return [Hash{Symbol => Object}] what the readers read
165
+ # @raise [InvalidAttribute] if the pattern asks for what the response holds as something that cannot be read
166
+ # @example Warn when the project has read nine tenths of its cap
167
+ # warn "near the cap" if usage in {project_usage: Integer => used, project_cap: Integer => cap} and used * 10 >= cap * 9
168
+ def deconstruct_keys(keys) = Utils.deconstruct(self, keys, %i[project_id project_usage project_cap cap_reset_day daily daily_by_app])
169
+
170
+ private
171
+
172
+ # Read daily usage entries, counting a day without a number as zero
173
+ # @api private
174
+ # @param reader [String] the name of the reader that reads them
175
+ # @param entries [Array<Hash>, nil] the entries, each with a date and a usage
176
+ # @return [Hash{Time => Integer}] the number of posts, keyed by the start of each day
177
+ # @raise [InvalidAttribute] if an entry has no date in ISO 8601, or a number that is not one, or the entries are
178
+ # not a list of objects
179
+ def days(reader, entries)
180
+ counts = {} #: Hash[Time, Integer]
181
+ entries = Shape.objects("#{self.class}##{reader}", entries)
182
+ entries.each { |entry| counts[time(reader, entry["date"])] = integer(reader, entry["usage"]) || 0 }
183
+ counts.freeze
184
+ end
185
+
186
+ # Read an identifier or a count the response holds as an Integer
187
+ # @api private
188
+ # @param reader [String] the name of the reader that reads it
189
+ # @param value [String, Integer, nil] the value
190
+ # @return [Integer, nil] the Integer, or nil if the value is missing
191
+ # @raise [InvalidAttribute] if the value is not a number
192
+ def integer(reader, value) = Utils.read("#{self.class}##{reader}", value) { Shape.integer(value) }
193
+
194
+ # Read a date the response holds as a Time, which it must hold
195
+ # @api private
196
+ # @param reader [String] the name of the reader that reads it
197
+ # @param value [String, nil] the date, in ISO 8601
198
+ # @return [Time] the time
199
+ # @raise [InvalidAttribute] if the value is missing, or is not ISO 8601
200
+ def time(reader, value) = Utils.read("#{self.class}##{reader}", value) { Time.iso8601(value.to_s) }
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "media_ids"
4
+ require_relative "utils"
5
+
6
+ module X
7
+ module Resources
8
+ # Creating and deleting posts, and hiding replies, as the authenticated user
9
+ #
10
+ # Internal to x-resources: the methods it gives Post, such as X::Post.create, are public API, but the module is only
11
+ # how they are shared, and which classes extend or include it can change within 1.x.
12
+ #
13
+ # @api semipublic
14
+ module PostWrites
15
+ # Create a post as the authenticated user
16
+ #
17
+ # The API bills each post created, and bills a post whose text holds a URL more than ten times as much.
18
+ # A post needs no text when it has something else to show, such as media.
19
+ #
20
+ # @api public
21
+ # @param text [String, nil] the text of the post, or nil for a post without text, such as one of media alone
22
+ # @param client [Object] the client used to make the request
23
+ # @param reply_to [Post, String, Integer, nil] the post to reply to or its identifier
24
+ # @param quote [Post, String, Integer, nil] the post to quote or its identifier
25
+ # @param media_ids [Array<String, Integer, #fetch, Media>, String, Integer, #fetch, Media, nil] the identifiers or
26
+ # media keys of uploaded media to attach, what the uploads returned, or media, such as that of a post, one or
27
+ # many; an empty list attaches nothing, as nil does
28
+ # @param community [Community, String, Integer, nil] the community to post in or its identifier
29
+ # @param params [Hash] additional request body fields, such as poll or reply_settings, among them reply and
30
+ # media, whose other fields reply_to and media_ids are merged into, each named by a String or a Symbol
31
+ # @return [Post] the created post, holding only its identifier and text
32
+ # @raise [ArgumentError] if the post has neither text nor any other field, which an empty media_ids is not
33
+ # @raise [MissingResource] if the API answers without the post
34
+ # @example Create a post
35
+ # X::Post.create("Hello, World!", client: client)
36
+ # @example Post an image without text
37
+ # X::Post.create(client: client, media_ids: media)
38
+ # @example Reply to a post with an image
39
+ # X::Post.create("Hello!", client: client, reply_to: post, media_ids: [media["id"]])
40
+ # @example Reply without the users a thread would otherwise mention
41
+ # X::Post.create("Hello!", client: client, reply_to: post, reply: {exclude_reply_user_ids: ["7505382"]})
42
+ # @example Post in a community
43
+ # X::Post.create("Hello, Rubyists!", client: client, community: community)
44
+ # @example Quote a post
45
+ # X::Post.create("Worth reading", client: client, quote: post)
46
+ def create(text = nil, client:, reply_to: nil, quote: nil, media_ids: nil, community: nil, **params)
47
+ params = Utils.fields(params)
48
+ fields = {text:, **params, **referenced(params, reply_to:, quote:, media_ids:, community:)}.compact
49
+ raise ArgumentError, "a post needs text, or something else to show, such as media_ids" if fields.empty?
50
+
51
+ created_from_response(client.post("tweets", fields, **Utils::JSON_CLASSES), "POST tweets", client:)
52
+ end
53
+
54
+ # Delete a post as the authenticated user
55
+ #
56
+ # @api public
57
+ # @param post [Post, String, Integer] the post or its identifier
58
+ # @param client [Object] the client used to make the request
59
+ # @return [Boolean] true if the post was deleted
60
+ # @example Delete a post
61
+ # X::Post.delete("1234567890", client: client)
62
+ def delete(post, client:)
63
+ body = client.delete("tweets/#{Utils.id_of(post, Post)}", **Utils::JSON_CLASSES)
64
+ Utils.written(body, "deleted").eql?(true)
65
+ end
66
+
67
+ # Hide a reply to a post of the authenticated user
68
+ #
69
+ # X still shows a hidden reply, behind a notice that its author was hidden.
70
+ #
71
+ # @api public
72
+ # @param post [Post, String, Integer] the reply or its identifier
73
+ # @param client [Object] the client used to make the request
74
+ # @return [Boolean] true if the reply is now hidden
75
+ # @example Hide a reply
76
+ # X::Post.hide_reply("1234567890", client: client)
77
+ def hide_reply(post, client:)
78
+ change_visibility(post, true, client:).eql?(true)
79
+ end
80
+
81
+ # Show a reply that was hidden, as the author of the post it replies to
82
+ #
83
+ # @api public
84
+ # @param post [Post, String, Integer] the reply or its identifier
85
+ # @param client [Object] the client used to make the request
86
+ # @return [Boolean] true if the reply is no longer hidden
87
+ # @example Show a hidden reply
88
+ # X::Post.unhide_reply("1234567890", client: client)
89
+ def unhide_reply(post, client:)
90
+ change_visibility(post, false, client:).eql?(false)
91
+ end
92
+
93
+ private
94
+
95
+ # Hide or show a reply, and read whether it is hidden
96
+ # @api private
97
+ # @param post [Post, String, Integer] the reply or its identifier
98
+ # @param hidden [Boolean] whether to hide the reply
99
+ # @param client [Object] the client used to make the request
100
+ # @return [Boolean, nil] whether the reply is hidden, as the response reports, or nil if it does not
101
+ def change_visibility(post, hidden, client:)
102
+ Utils.written(client.put("tweets/#{Utils.id_of(post, Post)}/hidden", {hidden:}, **Utils::JSON_CLASSES), "hidden")
103
+ end
104
+
105
+ # The fields of a new post that refer to other posts, media, or a community
106
+ #
107
+ # The API nests the post replied to within reply, and the media within media, beside other fields a caller may
108
+ # set, so reply_to and media_ids are merged into what the caller gave rather than replace it, and they win the
109
+ # one field each of them sets. An empty media_ids sets no media field, which the API would refuse.
110
+ #
111
+ # @api private
112
+ # @param params [Hash] the request body fields the caller gave, such as reply, media, or poll
113
+ # @param reply_to [Post, String, Integer, nil] the post to reply to or its identifier
114
+ # @param quote [Post, String, Integer, nil] the post to quote or its identifier
115
+ # @param media_ids [Array, #fetch, Media, String, Integer, nil] the identifiers of uploaded media, what the uploads
116
+ # returned, or media, one or many
117
+ # @param community [Community, String, Integer, nil] the community to post in or its identifier
118
+ # @return [Hash{Symbol => Object}] the fields, without those given nil
119
+ def referenced(params, reply_to:, quote:, media_ids:, community:)
120
+ fields = {} #: Hash[Symbol, untyped]
121
+ fields[:reply] = merged(params, :reply, in_reply_to_tweet_id: Utils.id_of(reply_to, Post)) unless reply_to.nil?
122
+ fields[:quote_tweet_id] = Utils.id_of(quote, Post) unless quote.nil?
123
+ ids = MediaIds.media_ids_of(media_ids)
124
+ fields[:media] = merged(params, :media, media_ids: ids) unless ids.empty?
125
+ fields[:community_id] = Utils.id_of(community, Community) unless community.nil?
126
+ fields
127
+ end
128
+
129
+ # One nested field, with the convenience key merged over what the caller gave
130
+ #
131
+ # @api private
132
+ # @param params [Hash{Symbol => Object}] the request body fields the caller gave
133
+ # @param key [Symbol] the nested field, reply or media
134
+ # @param field [Hash] the one field the convenience key sets, which wins over the caller's, by a String or a Symbol
135
+ # @return [Hash{Symbol => Object}] the nested field
136
+ def merged(params, key, **field) = Utils.fields(params[key]).merge(field)
137
+ end
138
+ private_constant :PostWrites
139
+ end
140
+ end