x-uploads 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.
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "x/core"
4
+ require_relative "media_processing_failed"
5
+ require_relative "media_processing_timeout"
6
+ require_relative "missing_media_data"
7
+ require_relative "multipart"
8
+ require_relative "uploaded_media"
9
+
10
+ module X
11
+ module Uploads
12
+ # Helpers shared across the uploaders
13
+ #
14
+ # Internal to x-uploads: the uploaders call it rather than mix its methods into themselves, so a class that
15
+ # includes an uploader gains none of them.
16
+ #
17
+ # @api private
18
+ module Utils
19
+ extend self
20
+
21
+ # The media categories the subtitles endpoint takes, by the category the video was uploaded as
22
+ SUBTITLED_MEDIA_CATEGORIES = {"tweet_video" => "TweetVideo", "amplify_video" => "AmplifyVideo"}.freeze
23
+ # The message of the error raised for media that holds no identifier
24
+ NO_MEDIA_ID = "The media given holds no identifier"
25
+ # The message of the error raised for a response of an upload that holds no media
26
+ NO_MEDIA = "The response %s holds no media"
27
+ # The message of the error raised for a metadata response that holds no metadata
28
+ NO_METADATA = "The response that adds the metadata holds none"
29
+ # The message of the error raised for something that is neither media nor the identifier of media
30
+ NOT_MEDIA = "%s is not media: pass uploaded media, the Hash of an upload response, media that has a media key, " \
31
+ "such as X::Media, a media key, or a media identifier"
32
+ # The message of the error raised for a media key that names no media identifier
33
+ NOT_MEDIA_KEY = "The media key %s names no media identifier"
34
+ # The pattern of a media key, which names the media identifier after the number of its type and an underscore
35
+ MEDIA_KEY = /\A\d+_(\d+)\z/
36
+ # The pattern of a media identifier the API takes: one to nineteen digits
37
+ MEDIA_ID = /\A\d{1,19}\z/
38
+ # The message of the error raised for a media identifier the API would refuse
39
+ NOT_MEDIA_ID = "The media identifier %s is none the API takes, which is 1 to 19 digits"
40
+ # Fewest seconds to wait before a check of processing, for a status that asks for no wait, as one in a state X
41
+ # does not document may
42
+ MIN_CHECK_AFTER_SECS = 1
43
+ private_constant :NO_MEDIA_ID, :NO_MEDIA, :NO_METADATA, :NOT_MEDIA, :NOT_MEDIA_KEY, :MEDIA_KEY, :MEDIA_ID, :NOT_MEDIA_ID,
44
+ :MIN_CHECK_AFTER_SECS
45
+
46
+ # The lowercase extension of a file, without its dot
47
+ #
48
+ # @api private
49
+ # @param file_path [String, Pathname] the path to the file
50
+ # @return [String] the extension
51
+ # @example The extension of a file
52
+ # Uploads::Utils.extension("cat.JPG") # => "jpg"
53
+ def extension(file_path) = File.extname(file_path).delete(".").downcase
54
+
55
+ # The options of an uploader a method of a client was given, which name no client
56
+ #
57
+ # A method of a client uploads with that client, so a client among the options, which would upload with the
58
+ # credentials of another, raises as a keyword the method does not take raises, rather than take its place.
59
+ #
60
+ # @api private
61
+ # @param options [Hash{Symbol => Object}] the options the method was given
62
+ # @return [Hash{Symbol => Object}] the options
63
+ # @raise [ArgumentError] if the options name a client
64
+ # @example The options of an upload of a client
65
+ # Uploads::Utils.without_client(media_category: "tweet_image") # => {media_category: "tweet_image"}
66
+ def without_client(options)
67
+ raise ArgumentError, "unknown keyword: :client" if options.key?(:client)
68
+
69
+ options
70
+ end
71
+
72
+ # The media identifier of an upload response, of media, or of an identifier
73
+ #
74
+ # Media that is not what an upload returned, such as the X::Media of x-resources, which this gem does not depend
75
+ # on, is read from its media key, which names the identifier after the number of its type, as 3_7 names 7, and
76
+ # a String that is a media key is read the same way, as the media_ids of a new post read one.
77
+ #
78
+ # Nil, or an empty identifier, names no media, and would reach the API as an identifier that is not there, so it
79
+ # raises ArgumentError, as a mistake of the caller: every response an upload builds media from holds an
80
+ # identifier, or raises MissingMediaData where it is read. Anything else raises rather than reach the API as
81
+ # whatever its to_s reads, such as the inspection of an object, and an identifier is read as strictly as
82
+ # UploadedMedia reads one: an Integer, or a String of digits alone, with no sign, underscore, or whitespace, so
83
+ # that the identifier of a Hash that is a Symbol or a Float raises too.
84
+ #
85
+ # @api private
86
+ # @param media [UploadedMedia, Hash, #media_key, String, Integer] the uploaded media, the upload response, media
87
+ # that has a media key, the media key, or the media identifier
88
+ # @return [String] the media identifier
89
+ # @raise [ArgumentError] if the media is nil or empty, neither media, a media key, nor a media identifier, holds
90
+ # no identifier, has no media key or one that names no identifier, or its identifier is none the API takes,
91
+ # which is 1 to 19 digits
92
+ # @example The identifier of uploaded media
93
+ # Uploads::Utils.media_id({"id" => "1880028106020515840"}) # => "1880028106020515840"
94
+ # @example The identifier of media of a post
95
+ # Uploads::Utils.media_id(X::Media.new(media_key: "3_1880028106020515840")) # => "1880028106020515840"
96
+ # @example The identifier a media key names
97
+ # Uploads::Utils.media_id("3_1880028106020515840") # => "1880028106020515840"
98
+ def media_id(media)
99
+ id = case media
100
+ when Hash, UploadedMedia then media.fetch("id", nil)
101
+ when String then media[MEDIA_KEY, 1] || media
102
+ when Integer, nil then media
103
+ else media_key_id(media)
104
+ end
105
+ checked_media_id(id)
106
+ end
107
+
108
+ # A media identifier, as the String the API takes, once it is checked
109
+ #
110
+ # @api private
111
+ # @param id [Object] the identifier media holds, or its media key names
112
+ # @return [String] the media identifier
113
+ # @raise [ArgumentError] if the identifier is nil or empty, or is neither an Integer nor a String of 1 to 19
114
+ # digits alone
115
+ # @example Check the identifier of uploaded media
116
+ # Uploads::Utils.checked_media_id(7) # => "7"
117
+ def checked_media_id(id)
118
+ text = id.to_s
119
+ raise ArgumentError, NO_MEDIA_ID if text.empty?
120
+ return text if (Integer === id || String === id) && text.match?(MEDIA_ID)
121
+
122
+ raise ArgumentError, format(NOT_MEDIA_ID, id.inspect)
123
+ end
124
+
125
+ # The media identifier the media key of media names
126
+ #
127
+ # @api private
128
+ # @param media [#media_key, Object] the media
129
+ # @return [String, nil] the media identifier, or nil for media that has no media key
130
+ # @raise [ArgumentError] if the media has no media_key, or its media key names no identifier
131
+ # @example The identifier of media of a post
132
+ # Uploads::Utils.media_key_id(X::Media.new(media_key: "3_7")) # => "7"
133
+ def media_key_id(media)
134
+ raise ArgumentError, format(NOT_MEDIA, media.inspect) unless media.respond_to?(:media_key)
135
+
136
+ key = media.media_key
137
+ key && (key.to_s[MEDIA_KEY, 1] || raise(ArgumentError, format(NOT_MEDIA_KEY, key.inspect)))
138
+ end
139
+
140
+ # Media as uploaded media, which what an upload returned already is
141
+ #
142
+ # The Hash of an upload response is built into the uploaded media it describes, once its identifier is checked as
143
+ # the identifier of any media given is, and media known by a media key or by a media identifier into uploaded
144
+ # media that holds its identifier, and its media key if it has one.
145
+ #
146
+ # @api private
147
+ # @param media [UploadedMedia, Hash, #media_key, String, Integer] the uploaded media, the upload response, media
148
+ # that has a media key, the media key, or the media identifier
149
+ # @return [UploadedMedia] the uploaded media
150
+ # @raise [ArgumentError] if the media is nil or empty, neither media, a media key, nor a media identifier, holds
151
+ # no identifier the API takes, or its media key names none
152
+ # @example Uploaded media known by its identifier
153
+ # Uploads::Utils.uploaded_media(7) # => #<X::UploadedMedia id=7 media_key=nil state=nil>
154
+ def uploaded_media(media)
155
+ case media
156
+ when UploadedMedia then media
157
+ when Hash
158
+ media_id(media)
159
+ UploadedMedia.new(media)
160
+ else
161
+ keyed = media #: untyped
162
+ UploadedMedia.new({"id" => media_id(media), "media_key" => (keyed.media_key if keyed.respond_to?(:media_key))}.compact)
163
+ end
164
+ end
165
+
166
+ # The media metadata was added to, once the response says it was added
167
+ #
168
+ # The API answers a change of metadata with the media identifier and the metadata it now holds, under the data
169
+ # of the response, which a response that succeeded without it does not say were added.
170
+ #
171
+ # @api private
172
+ # @param response [Hash, nil] the parsed response body, or nil for a response without one
173
+ # @param media [UploadedMedia, Hash, #media_key, String, Integer] the media the metadata was added to
174
+ # @return [UploadedMedia] the media, as uploaded media
175
+ # @raise [MissingMediaData] if the response holds no metadata
176
+ # @example The media alt text was added to
177
+ # Uploads::Utils.described({"data" => {"id" => "7"}}, media) # => media
178
+ def described(response, media)
179
+ body = body_of(response)
180
+ raise MissingMediaData.new(NO_METADATA, problems: Problem.all_from(body)) unless Hash.try_convert(body["data"])
181
+
182
+ uploaded_media(media)
183
+ end
184
+
185
+ # The media a response of an upload describes
186
+ #
187
+ # The API answers an upload with what it acted on, under the data of the response, which always holds the
188
+ # identifier of the media. A response that succeeded without it describes no media, whether it carries no body
189
+ # at all, a body without data, or data whose identifier is missing, nil, or empty, so it raises rather than
190
+ # leave the upload to fail later on what is missing, or return media that cannot be attached for media the API
191
+ # may have billed. What it returns holds an identifier, so media that holds none was given by the caller.
192
+ #
193
+ # @api private
194
+ # @param response [Hash, nil] the parsed response body, or nil for a response without one
195
+ # @param description [String] how the error names the response, for its message
196
+ # @return [Hash] the media
197
+ # @raise [MissingMediaData] if the response holds no media, or media without an identifier
198
+ # @example The media an upload returned
199
+ # Uploads::Utils.media_data({"data" => {"id" => 7}}, "of the upload") # => {"id" => 7}
200
+ def media_data(response, description)
201
+ body = body_of(response)
202
+ media = body["data"]
203
+ return media if UploadedMedia.__send__(:documented?, media)
204
+
205
+ raise MissingMediaData.new(format(NO_MEDIA, description), problems: Problem.all_from(body))
206
+ end
207
+
208
+ # Upload media in a single request
209
+ #
210
+ # An image is uploaded so, as is a GIF a single request takes.
211
+ #
212
+ # @api private
213
+ # @param client [Client] the X API client
214
+ # @param content [String] the whole of the media, once its category is known to take it
215
+ # @param media_category [String] the media category, in lowercase
216
+ # @param additional_owners [Array<Integer, String>, nil] the identifiers of the users who may use the media, or
217
+ # nil for none, which the request sends as a list separated by commas
218
+ # @return [UploadedMedia] the uploaded media, which holds the upload response
219
+ # @raise [MissingMediaData] if the response holds no media, or carries no body at all
220
+ # @example Upload an image
221
+ # Uploads::Utils.single_request(client, png, "tweet_image", additional_owners: nil)
222
+ def single_request(client, content, media_category, additional_owners:)
223
+ response = Multipart.post(client, "media/upload", "media", content, media_category:, additional_owners: additional_owners&.join(","))
224
+ UploadedMedia.new(media_data(response, "of the upload"))
225
+ end
226
+
227
+ # The body of a response as an object, empty for one that holds none
228
+ #
229
+ # It is empty for a response that holds something other than an object, as a gateway may answer with.
230
+ #
231
+ # @api private
232
+ # @param response [Hash, Object, nil] the parsed body of the response
233
+ # @return [Hash] the body, or an empty Hash
234
+ # @example Read the body of a response
235
+ # X::Uploads::Utils.body_of(response)["data"]
236
+ def body_of(response) = Hash.try_convert(response).to_h
237
+
238
+ # Send a request again after a server or network error, as an idempotent one is
239
+ #
240
+ # A client sends no POST again, since the API may have acted on one whose answer never arrived, but some of
241
+ # the POSTs of an upload have the same effect sent twice as sent once, such as a chunk, which names the segment
242
+ # it is appended at. Those are sent again with the with_retries of the client, up to its max_retries, after the
243
+ # wait a failed response asks for, or a backoff that grows with each retry and is cut short at random, so that
244
+ # the requests one failure ended are not sent again together. They are sent again after a timeout too, which a
245
+ # client sends no read again after, since a lost answer would otherwise fail the upload; X bills nothing for a
246
+ # chunk, but bills each metadata request, so alt text or subtitles whose answer was lost may be billed twice. A
247
+ # client that has no with_retries, which X::Client has, sends each request as it sends any other.
248
+ #
249
+ # @api private
250
+ # @param client [Client] the X API client
251
+ # @yield sends the request
252
+ # @return [Object] what the block returns
253
+ # @example Append a chunk, again after a failure
254
+ # Uploads::Utils.sending_again(client) { client.post("media/upload/1/append", body, headers:) }
255
+ def sending_again(client, &)
256
+ retrying = client #: untyped
257
+ retrying.respond_to?(:with_retries) ? retrying.with_retries(&) : yield
258
+ end
259
+
260
+ # The processing status of media, unless the media failed to process
261
+ #
262
+ # The failed state alone is a failure: media whose processing names no state, or a state X does not document, is
263
+ # still processing, as a state X adds may be one it passes through, so it is waited for, not raised for. A
264
+ # response that holds no processing is of media X does not process, whatever else it holds.
265
+ #
266
+ # @api private
267
+ # @param status [UploadedMedia] the processing status X reported, or the response of an upload
268
+ # @return [UploadedMedia] the status, of media that has processed, is still processing, or needs no processing
269
+ # @raise [MediaProcessingFailed] if the media failed to process, with the status
270
+ # @example The status of media that has processed
271
+ # Uploads::Utils.processed!(status) # => status
272
+ def processed!(status)
273
+ raise MediaProcessingFailed.new(media: status) if status.failed?
274
+
275
+ status
276
+ end
277
+
278
+ # Wait as long as the status of media still processing asks
279
+ #
280
+ # It waits at least a second, which is how long it waits for a status that asks for no wait, as one in a state X
281
+ # does not document may, and gives up, rather than sleep, when the check would come after the deadline, which a
282
+ # wait with no deadline never does.
283
+ #
284
+ # @api private
285
+ # @param status [UploadedMedia] the status of the media, which is still processing
286
+ # @param deadline [Float, nil] the time on the monotonic clock to give up at, or nil to wait for as long as it takes
287
+ # @param timeout [Integer, Float, nil] the seconds the deadline was set from, which the error names
288
+ # @return [void]
289
+ # @raise [MediaProcessingTimeout] if the check would come after the deadline
290
+ # @example Wait before checking the status of media again
291
+ # Uploads::Utils.wait_to_check(status, deadline: Uploads::Utils.seconds_from_now(600), timeout: 600)
292
+ def wait_to_check(status, deadline:, timeout:)
293
+ wait = [status.check_after_secs.to_i, MIN_CHECK_AFTER_SECS].max
294
+ raise MediaProcessingTimeout.new(media: status, timeout:) if deadline && seconds_from_now(wait) > deadline
295
+
296
+ sleep wait
297
+ end
298
+
299
+ # The time on the monotonic clock some seconds from now, which keeps a deadline
300
+ #
301
+ # The monotonic clock counts from no time in particular, but never goes back, as the time of day does when the
302
+ # clock of the system is set, so the seconds between two of its times are the seconds that passed between them.
303
+ #
304
+ # @api private
305
+ # @param seconds [Integer, Float] the seconds from now
306
+ # @return [Float] the time on the monotonic clock
307
+ # @example The time on the monotonic clock a minute from now
308
+ # Uploads::Utils.seconds_from_now(60) # => 1234.5
309
+ def seconds_from_now(seconds) = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
310
+
311
+ # The media category the subtitles endpoint takes, from one given in any form
312
+ #
313
+ # The uploaders take a category as tweet_video, in any case, and the endpoint names it TweetVideo, so both are
314
+ # taken, and any other category raises before a request the endpoint would refuse.
315
+ #
316
+ # @api private
317
+ # @param media_category [String, Symbol] the media category, in any form
318
+ # @return [String] the media category as the subtitles endpoint names it
319
+ # @raise [ArgumentError] if the media category is neither tweet_video nor amplify_video
320
+ # @example The category of subtitles for a video attached to a post
321
+ # Uploads::Utils.subtitled_media_category(:tweet_video) # => "TweetVideo"
322
+ def subtitled_media_category(media_category)
323
+ category = media_category.to_s.downcase
324
+ _, name = SUBTITLED_MEDIA_CATEGORIES.find { |key, value| [key, value.downcase].include?(category) }
325
+ name or raise ArgumentError, "Invalid media_category: #{media_category}. Valid values: #{SUBTITLED_MEDIA_CATEGORIES.keys.join(", ")}"
326
+ end
327
+ end
328
+ private_constant :Utils
329
+ end
330
+ end