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,391 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "invalid_media"
4
+ require_relative "invalid_media_type"
5
+ require_relative "signature"
6
+ require_relative "source"
7
+
8
+ module X
9
+ module Uploads
10
+ # Validates media upload parameters
11
+ #
12
+ # Internal to x-uploads: the uploaders validate their arguments with it.
13
+ #
14
+ # @api private
15
+ module Validator
16
+ extend self
17
+
18
+ # Number of bytes per megabyte
19
+ BYTES_PER_MB = 1_048_576
20
+ # Greatest number of characters of alt text the API takes
21
+ MAX_ALT_TEXT_LENGTH = 1000
22
+ # The pattern of the language code of subtitles, two letters, which the API takes in upper case
23
+ LANGUAGE_CODE = /\A[a-z]{2}\z/i
24
+ # The identifier of a user, as the API takes it among the additional owners of media
25
+ USER_ID = /\A[0-9]{1,19}\z/
26
+ # Greatest number of segments an upload in chunks can have: the OpenAPI specification of the API v2 takes a
27
+ # segment_index of 0 to 9999, so an upload in more chunks than these would fail partway, once the media uploaded
28
+ # so far had been billed. Chunks of DEFAULT_CHUNK bytes upload 41 GB of media in them, more than the
29
+ # MAX_UPLOAD_BYTES the API takes of any upload
30
+ MAX_SEGMENTS = 10_000
31
+ # Greatest number of bytes in a segment of an upload in chunks: the guide to chunked uploads says to keep each
32
+ # segment at or below 5 MB, of the 8 MB the server takes at most, so a chunk is 5 megabytes at most, which is
33
+ # below 8 MB whether a megabyte is read as 1,000,000 bytes or as 1,048,576
34
+ MAX_CHUNK = 5 * BYTES_PER_MB
35
+ # Number of bytes in a chunk of an upload given no chunk size, unless the media needs larger ones to fit the
36
+ # MAX_SEGMENTS the API numbers: each chunk is a request, any of which a rate limit can refuse, which fails the
37
+ # upload unless the client waits it out, so a chunk is 4 megabytes, within the MAX_CHUNK of a segment, which
38
+ # sends a quarter of the requests chunks of a megabyte did
39
+ DEFAULT_CHUNK = 4 * BYTES_PER_MB
40
+ # Greatest number of chunks uploaded at once: each holds a chunk of up to MAX_CHUNK bytes in memory and a
41
+ # connection of its own, so 16 hold 80 megabytes on the 16 connections a client keeps open to a host, and more
42
+ # would hold more of both than an upload gains from, since the connections the client does not keep are opened
43
+ # again for each chunk
44
+ MAX_CONCURRENCY = 16
45
+ # Greatest number of bytes the API takes in a single upload request, above which an animated GIF, which it
46
+ # takes in chunks of up to 15 MB, uploads in chunks
47
+ MAX_SIMPLE_UPLOAD_BYTES = 5 * BYTES_PER_MB
48
+ # The media types of the images a profile image or banner takes
49
+ PROFILE_IMAGE_TYPES = %w[image/gif image/jpeg image/png].freeze
50
+ # The least number of pixels each dimension of the region of a profile banner takes: a width and a height hold at
51
+ # least one, and an offset none
52
+ BANNER_REGION = {width: 1, height: 1, offset_left: 0, offset_top: 0}.freeze
53
+ # Greatest number of bytes the API v1.1 takes of a profile image: its reference for update_profile_image takes an
54
+ # image of less than 700 kilobytes, each of 1,024 bytes, as a megabyte here is 1,048,576
55
+ MAX_PROFILE_IMAGE_BYTES = 700 * 1024
56
+ # Greatest number of bytes X takes of a profile banner: the reference of the API v1.1 for update_profile_banner
57
+ # names none, and answers a banner too large with 422, and the help of X gives 5 MB for a header photo
58
+ MAX_PROFILE_BANNER_BYTES = 5 * BYTES_PER_MB
59
+ # Valid media category values
60
+ MEDIA_CATEGORIES = %w[amplify_video dm_gif dm_image dm_video subtitles tweet_gif tweet_image tweet_video].map(&:freeze).freeze
61
+ # Greatest number of bytes the API takes of media of each category that documents a size of its own for every
62
+ # account: the guides to media on docs.x.com give 5 MB for an image and 15 MB for a GIF, whether or not the
63
+ # user has X Premium, and 1 MB for subtitles, and the API has refused an image of more than 5,242,880 bytes,
64
+ # so a megabyte is 1,048,576 bytes. The size of a video depends on the account, so none is checked but the
65
+ # MAX_UPLOAD_BYTES of any upload.
66
+ MAX_MEDIA_BYTES = {
67
+ "dm_image" => 5 * BYTES_PER_MB, "tweet_image" => 5 * BYTES_PER_MB, "dm_gif" => 15 * BYTES_PER_MB,
68
+ "tweet_gif" => 15 * BYTES_PER_MB, "subtitles" => BYTES_PER_MB
69
+ }.freeze
70
+ # Greatest number of bytes the API takes of any upload: the OpenAPI specification of the API v2 takes a
71
+ # total_bytes of at most 17,179,869,184, 16 gigabytes of 1,073,741,824 bytes, to initialize an upload with
72
+ MAX_UPLOAD_BYTES = 16 * 1024 * BYTES_PER_MB
73
+
74
+ # Validate the arguments of an upload, and give its media category in lowercase
75
+ #
76
+ # It validates everything an upload can be refused for before it sends a request, so that no media is uploaded,
77
+ # and billed, for an upload that cannot finish. A media category of nil is inferred with the block, once the
78
+ # media is known to exist and hold something, so that media that holds nothing raises for that, rather than for
79
+ # a category nothing names.
80
+ #
81
+ # @api private
82
+ # @param source [Source] the media to upload
83
+ # @param media_category [String, Symbol, nil] the media category, in any case, or nil to infer it
84
+ # @param alt_text [String, nil] the alt text of the media, or nil for media described with none
85
+ # @param chunk_size [Integer, nil] the size of each chunk in bytes, or nil to derive one
86
+ # @param concurrency [Integer] the number of chunks uploaded at once, of 1 to MAX_CONCURRENCY
87
+ # @param processing_timeout [Integer, Float] the seconds to wait for the media to process
88
+ # @param shared [Boolean, nil] whether the media is shared, or nil to leave it to the API
89
+ # @param additional_owners [Array<Integer, String>, nil] the identifiers of the users who may use the media, or
90
+ # nil for none
91
+ # @yieldreturn [String, Symbol] the media category inferred from the media, when none is given
92
+ # @return [String] the media category in lowercase
93
+ # @raise [InvalidMedia] if the file does not exist
94
+ # @raise [InvalidMedia] if the media cannot be read, is empty, or is larger than the API takes of its category
95
+ # @raise [ArgumentError] if the media category is invalid, the alt text is empty or too long, the chunk size is
96
+ # not a positive Integer or is larger than a segment the API takes, the concurrency is not 1 to
97
+ # MAX_CONCURRENCY, the processing timeout is not a number of seconds, shared is neither true, false, nor nil,
98
+ # or additional_owners is neither nil nor an Array of at least one user identifier
99
+ # @example Validate the arguments of an upload
100
+ # Uploads::Validator.validate_upload!(source, :TWEET_IMAGE, alt_text: nil, chunk_size: nil, concurrency: 4,
101
+ # processing_timeout: 300) # => "tweet_image"
102
+ def validate_upload!(source, media_category, alt_text:, chunk_size:, concurrency:, processing_timeout:, shared: nil, additional_owners: nil)
103
+ validate_sharing!(shared, additional_owners)
104
+ validate_source!(source)
105
+ validate_alt_text!(alt_text)
106
+ validate_chunks!(chunk_size:, concurrency:)
107
+ validate_processing_timeout!(processing_timeout)
108
+ validate_media_category!(media_category || yield).tap { |category| validate_size!(source, category) }
109
+ end
110
+
111
+ # Validate that media is no larger than the API takes of its category
112
+ #
113
+ # An image, a GIF, or subtitles larger than the MAX_MEDIA_BYTES of its category would be refused once it had
114
+ # been uploaded, and billed, so it raises before a request. A video, whose size depends on the account, is left
115
+ # to the API, unless it is larger than the MAX_UPLOAD_BYTES the API takes of any upload, which it would refuse
116
+ # to initialize.
117
+ #
118
+ # @api private
119
+ # @param source [Source] the media to upload
120
+ # @param media_category [String] the media category, in lowercase
121
+ # @return [void]
122
+ # @raise [InvalidMedia] if the media is larger than the API takes of its category
123
+ # @example Validate the size of an image
124
+ # Uploads::Validator.validate_size!(source, "tweet_image")
125
+ def validate_size!(source, media_category)
126
+ limit = MAX_MEDIA_BYTES.fetch(media_category, MAX_UPLOAD_BYTES)
127
+ return if source.size <= limit
128
+
129
+ raise InvalidMedia, "#{source.description} is #{source.size} bytes, more than the #{limit} bytes the API takes of #{media_category} media"
130
+ end
131
+
132
+ # Validate that the media exists, and that it holds something to upload
133
+ #
134
+ # Empty media would initialize an upload in chunks and finalize it without a chunk, or send a single request
135
+ # without media, for the API to refuse either.
136
+ #
137
+ # @api private
138
+ # @param source [Source] the media to validate
139
+ # @return [void]
140
+ # @raise [InvalidMedia] if the file does not exist
141
+ # @raise [InvalidMedia] if the media cannot be read, or is empty
142
+ # @example Validate the media of an upload
143
+ # Uploads::Validator.validate_source!(source)
144
+ def validate_source!(source)
145
+ raise InvalidMedia, "#{source.description} does not exist: there is no file to upload" unless source.exist?
146
+ raise InvalidMedia, "#{source.description} cannot be read: it is not a file, or not one open for reading" unless source.readable?
147
+ raise InvalidMedia, "#{source.description} is empty: there is nothing to upload" if source.size.zero?
148
+ end
149
+
150
+ # Validate an image to upload as a profile image or banner
151
+ #
152
+ # The image must begin with the signature of a GIF, a JPEG, or a PNG, which are the types those take, whatever
153
+ # the name of its file, so that a PNG in a Tempfile is taken, and a video named .png is not read whole and sent.
154
+ # An image larger than the endpoint takes would be refused once it had been sent, so it raises before.
155
+ #
156
+ # @api private
157
+ # @param source [Source] the image to upload
158
+ # @param max_bytes [Integer] the greatest number of bytes the endpoint takes
159
+ # @param use [String] what the image is uploaded as, for the message of an error
160
+ # @return [void]
161
+ # @raise [InvalidMedia] if the file does not exist
162
+ # @raise [InvalidMedia] if the image cannot be read, is empty, or is larger than max_bytes
163
+ # @raise [InvalidMediaType] if the image does not begin with the signature of a GIF, a JPEG, or a PNG
164
+ # @example Validate a profile image
165
+ # Uploads::Validator.validate_profile_image!(source, 716_800, "a profile image")
166
+ def validate_profile_image!(source, max_bytes, use)
167
+ validate_source!(source)
168
+ raise InvalidMediaType, "#{source.description} is not a GIF, JPEG, or PNG image, which #{use} must be" unless PROFILE_IMAGE_TYPES.include?(Signature.media_type(source.sniff))
169
+ return if source.size <= max_bytes
170
+
171
+ raise InvalidMedia, "#{source.description} is #{source.size} bytes, more than the #{max_bytes} bytes the API takes of #{use}"
172
+ end
173
+
174
+ # Validate the region of a profile banner to crop it to
175
+ #
176
+ # The reference of the API v1.1 for update_profile_banner takes the width and height of the region of the image
177
+ # to use, and its offsets from the left and the top, each in pixels. A width or height is a positive Integer,
178
+ # and an offset an Integer of at least 0, or nil for none, so that anything else, such as a String read from a
179
+ # form, raises before any request, rather than be sent as whatever its to_s reads.
180
+ #
181
+ # @api private
182
+ # @param region [Hash{Symbol => Integer, nil}] the width and height of the region, and its offset_left and
183
+ # offset_top, in pixels
184
+ # @return [void]
185
+ # @raise [ArgumentError] if a width or height is not a positive Integer, or an offset not an Integer of at
186
+ # least 0, and is not nil
187
+ # @example Validate the region of a banner
188
+ # Uploads::Validator.validate_banner_region!(width: 1500, height: 500, offset_left: 0, offset_top: nil)
189
+ def validate_banner_region!(**region)
190
+ region.each do |name, pixels|
191
+ least = BANNER_REGION.fetch(name)
192
+ next if pixels.nil? || whole_number?(pixels, least)
193
+
194
+ raise ArgumentError, "#{name} must be an Integer of pixels of at least #{least}, or nil, not #{pixels.inspect}"
195
+ end
196
+ end
197
+
198
+ # Validate whether media is shared, and the users given access to it
199
+ #
200
+ # Media is shared, or not, with true or false, so that a value read from a String, such as "false", which is
201
+ # truthy, does not share it by accident. The users given access to it are named by their identifiers, each an
202
+ # Integer or a String of digits, as the API takes them, of at least one user, since a list of none names no one.
203
+ #
204
+ # @api private
205
+ # @param shared [Boolean, nil] whether the media is shared, or nil to leave it to the API
206
+ # @param additional_owners [Array<Integer, String>, nil] the identifiers of the users who may use the media, or
207
+ # nil for none
208
+ # @return [void]
209
+ # @raise [ArgumentError] if shared is neither true, false, nor nil, or additional_owners is neither nil nor an
210
+ # Array of at least one user identifier
211
+ # @example Validate media shared with another user
212
+ # Uploads::Validator.validate_sharing!(true, [7_505_382])
213
+ def validate_sharing!(shared, additional_owners)
214
+ raise ArgumentError, "shared must be true, false, or nil, not #{shared.inspect}" unless [true, false, nil].include?(shared)
215
+ return if additional_owners.nil?
216
+ return if additional_owners.is_a?(Array) && !additional_owners.empty? && additional_owners.all? { |owner| user_id?(owner) }
217
+
218
+ raise ArgumentError, "additional_owners must be an Array of the identifiers of users, or nil for none, not #{additional_owners.inspect}"
219
+ end
220
+
221
+ # Check whether a value is the identifier of a user, as the API takes it
222
+ #
223
+ # @api private
224
+ # @param value [Object] the value
225
+ # @return [Boolean] true for an Integer, or a String of digits, of 1 to 19 digits
226
+ def user_id?(value) = (value.instance_of?(Integer) || value.is_a?(String)) && value.to_s.match?(USER_ID)
227
+
228
+ # Validate the alt text of an upload, of up to MAX_ALT_TEXT_LENGTH characters
229
+ #
230
+ # The alt text is sent as JSON, which is UTF-8, so text of another encoding is sent as the UTF-8 it converts to,
231
+ # and text that converts to none, such as bytes that are not UTF-8, is refused here, before the media it
232
+ # describes is uploaded, rather than fail to be sent once it is.
233
+ #
234
+ # @api private
235
+ # @param alt_text [String, nil] the alt text to validate, or nil for media described with none
236
+ # @return [void]
237
+ # @raise [ArgumentError] if the alt text is not a String, does not convert to UTF-8, or is empty or longer than
238
+ # the API takes
239
+ # @example Validate alt text
240
+ # Uploads::Validator.validate_alt_text!("A cat asleep on a keyboard")
241
+ def validate_alt_text!(alt_text)
242
+ return if alt_text.nil?
243
+ raise ArgumentError, "alt_text must be a String, or nil for none, not #{alt_text.inspect}" unless alt_text.is_a?(String)
244
+ raise ArgumentError, "alt_text must be text that converts to UTF-8, not #{alt_text.inspect}" unless utf8?(alt_text)
245
+ return if (1..MAX_ALT_TEXT_LENGTH).cover?(alt_text.length)
246
+
247
+ raise ArgumentError, "alt_text must be 1 to #{MAX_ALT_TEXT_LENGTH} characters, not #{alt_text.length}"
248
+ end
249
+
250
+ # Check whether text converts to UTF-8, as the JSON it is sent in is written
251
+ # @api private
252
+ # @param text [String] the text
253
+ # @return [Boolean] true if the text converts to valid UTF-8
254
+ # @example Check text that holds a byte that is not UTF-8
255
+ # Uploads::Validator.utf8?("\xFF".b) # => false
256
+ def utf8?(text)
257
+ text.encode(Encoding::UTF_8).valid_encoding?
258
+ rescue EncodingError
259
+ false
260
+ end
261
+
262
+ # Validate the language code of subtitles, and upcase it, as the API takes it
263
+ #
264
+ # The code is two letters, in any case.
265
+ #
266
+ # @api private
267
+ # @param language_code [String] the language code, such as EN or en
268
+ # @return [String] the language code in upper case
269
+ # @raise [ArgumentError] if the language code is not two letters
270
+ # @example Validate a language code
271
+ # Uploads::Validator.validate_language_code!("en") # => "EN"
272
+ def validate_language_code!(language_code)
273
+ return language_code.upcase if language_code.is_a?(String) && language_code.match?(LANGUAGE_CODE)
274
+
275
+ raise ArgumentError, "language_code must be two letters, such as EN, not #{language_code.inspect}"
276
+ end
277
+
278
+ # Validate the chunk size and concurrency of a chunked upload
279
+ #
280
+ # A chunk size is a whole number of bytes, so anything else, such as a Float or a String read from an
281
+ # environment variable, raises ArgumentError, rather than be rounded to one. A chunk size above the MAX_CHUNK
282
+ # bytes of a segment raises too, since the server would refuse the first segment, once the upload had been
283
+ # initialized. A concurrency above MAX_CONCURRENCY raises as well, rather than hold a chunk and a connection for
284
+ # each of as many threads as it names.
285
+ #
286
+ # @api private
287
+ # @param chunk_size [Integer, nil] the size of each chunk in bytes, which must be positive and at most
288
+ # MAX_CHUNK, or nil for a chunk size derived from the file
289
+ # @param concurrency [Integer] the number of chunks uploaded at once, of 1 to MAX_CONCURRENCY
290
+ # @return [void]
291
+ # @raise [ArgumentError] if the chunk size is not a positive Integer, or is larger than a segment the API takes,
292
+ # or the concurrency is not an Integer of 1 to MAX_CONCURRENCY
293
+ # @example Validate the options of a chunked upload
294
+ # Uploads::Validator.validate_chunks!(chunk_size: 4_194_304, concurrency: 2)
295
+ def validate_chunks!(chunk_size:, concurrency:)
296
+ raise ArgumentError, "chunk_size must be a positive Integer of bytes, not #{chunk_size.inspect}" unless chunk_size.nil? || whole_number?(chunk_size, 1)
297
+ raise ArgumentError, "chunk_size must be at most #{MAX_CHUNK}, the bytes of a segment the API takes, not #{chunk_size}" if chunk_size && chunk_size > MAX_CHUNK
298
+ raise ArgumentError, "concurrency must be an Integer of 1 to #{MAX_CONCURRENCY}, not #{concurrency.inspect}" unless concurrency.instance_of?(Integer) && (1..MAX_CONCURRENCY).cover?(concurrency)
299
+ end
300
+
301
+ # Check whether a value is a whole number of at least the least given
302
+ #
303
+ # A number of bytes or pixels is one. A Float is not, even a whole one, nor is a Rational, so that no size is
304
+ # rounded to a byte or a pixel.
305
+ #
306
+ # @api private
307
+ # @param value [Object] the value
308
+ # @param least [Integer] the least the number may be
309
+ # @return [Boolean] true if the value is an Integer of at least the least given
310
+ def whole_number?(value, least) = value.instance_of?(Integer) && value >= least
311
+
312
+ # Validate the seconds to wait for media to process
313
+ #
314
+ # A processing timeout is a finite number of seconds of at least 0, or nil for an upload that waits for as long
315
+ # as processing takes, as a timeout of a client is.
316
+ #
317
+ # @api private
318
+ # @param processing_timeout [Integer, Float, nil] the seconds to wait, or nil for no timeout
319
+ # @return [void]
320
+ # @raise [ArgumentError] if the processing timeout is neither a finite number of seconds of at least 0 nor nil
321
+ # @example Validate a processing timeout
322
+ # Uploads::Validator.validate_processing_timeout!(1800)
323
+ def validate_processing_timeout!(processing_timeout)
324
+ return if processing_timeout.nil? || finite_seconds?(processing_timeout)
325
+
326
+ raise ArgumentError, "processing_timeout must be a finite number of seconds of at least 0, or nil for no " \
327
+ "timeout, not #{processing_timeout.inspect}"
328
+ end
329
+
330
+ # Check whether a value is a finite real number of at least 0
331
+ #
332
+ # @api private
333
+ # @param value [Object] the value
334
+ # @return [Boolean] true if the value is a finite real number of at least 0
335
+ def finite_seconds?(value) = value.is_a?(Numeric) && value.real? && !value.negative? && value.finite?
336
+
337
+ # The size in bytes of the chunks a file uploads in
338
+ #
339
+ # The API numbers no more than MAX_SEGMENTS segments, of no more than MAX_CHUNK bytes each, which an upload in
340
+ # more chunks, or in larger ones, would fail partway of.
341
+ #
342
+ # A chunk size of nil is derived from the size of the media: DEFAULT_CHUNK bytes, or the size that uploads the
343
+ # media in MAX_SEGMENTS chunks, whichever is larger, which media no larger than the MAX_UPLOAD_BYTES
344
+ # validate_size! takes uploads in chunks of DEFAULT_CHUNK bytes.
345
+ #
346
+ # @api private
347
+ # @param source [Source] the media to upload
348
+ # @param chunk_size [Integer, nil] the size of each chunk in bytes, or nil to derive one
349
+ # @return [Integer] the size of each chunk in bytes
350
+ # @raise [InvalidMedia] if the system refuses to read the size of the media, as it does that of a file that
351
+ # does not exist
352
+ # @raise [ArgumentError] if chunks of the size given would be more than the API numbers
353
+ # @example Derive the chunk size of a video
354
+ # Uploads::Validator.validate_segments!(source, nil) # => 4194304
355
+ def validate_segments!(source, chunk_size)
356
+ file_size = source.size
357
+ size = chunk_size || derived_chunk_size(file_size)
358
+ return size if file_size <= size * MAX_SEGMENTS
359
+
360
+ raise ArgumentError, "chunk_size of #{chunk_size} bytes uploads #{file_size} bytes in more than the #{MAX_SEGMENTS} segments the API numbers"
361
+ end
362
+
363
+ # The size in bytes of the chunks media is uploaded in when it is given none
364
+ #
365
+ # It is DEFAULT_CHUNK bytes, or the size that uploads the media in MAX_SEGMENTS chunks, whichever is larger.
366
+ #
367
+ # @api private
368
+ # @param file_size [Integer] the size of the media in bytes
369
+ # @return [Integer] the size of each chunk in bytes
370
+ # @example The chunk size of a video of sixteen gigabytes
371
+ # Uploads::Validator.derived_chunk_size(16 * 1024**3) # => 4194304
372
+ def derived_chunk_size(file_size) = [(file_size.to_f / MAX_SEGMENTS).ceil, DEFAULT_CHUNK].max
373
+
374
+ # Validate a media category, and give it in the lowercase the API takes
375
+ #
376
+ # @api private
377
+ # @param media_category [String, Symbol] the media category to validate, in any case
378
+ # @return [String] the media category in lowercase
379
+ # @raise [ArgumentError] if the media category is invalid
380
+ # @example Validate a media category
381
+ # Uploads::Validator.validate_media_category!(:TWEET_IMAGE) # => "tweet_image"
382
+ def validate_media_category!(media_category)
383
+ category = media_category.to_s.downcase
384
+ return category if MEDIA_CATEGORIES.include?(category)
385
+
386
+ raise ArgumentError, "Invalid media_category: #{media_category}. Valid values: #{MEDIA_CATEGORIES.join(", ")}"
387
+ end
388
+ end
389
+ private_constant :Validator
390
+ end
391
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems/version"
4
+
5
+ module X
6
+ # Uploads media, profile images, and banners to the X API
7
+ # @api public
8
+ module Uploads
9
+ # The current version of the x-uploads gem
10
+ VERSION = "1.0.0"
11
+
12
+ # The version as a Gem::Version, which compares one release with another
13
+ #
14
+ # VERSION is a String, as a version constant is throughout Ruby, so that what reads it can split it, match it,
15
+ # or send it wherever a String belongs. This builds the Gem::Version that compares it with another version,
16
+ # which a String compares by character rather than by segment.
17
+ #
18
+ # @api public
19
+ # @return [Gem::Version] the version
20
+ # @example Take a path that a later release opened
21
+ # X::Uploads.gem_version >= Gem::Version.new("1.1")
22
+ def self.gem_version = Gem::Version.new(VERSION)
23
+ end
24
+ end
data/lib/x/uploads.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "uploads/version"
4
+ require_relative "uploads/account"
5
+ require_relative "uploads/alt_text_failed"
6
+ require_relative "uploads/api"
7
+ require_relative "uploads/chunked_upload_failed"
8
+ require_relative "uploads/error"
9
+ require_relative "uploads/gif"
10
+ require_relative "uploads/invalid_media"
11
+ require_relative "uploads/invalid_media_type"
12
+ require_relative "uploads/media_upload"
13
+ require_relative "uploads/media_processing_check_failed"
14
+ require_relative "uploads/media_processing_failed"
15
+ require_relative "uploads/media_processing_timeout"
16
+ require_relative "uploads/metadata"
17
+ require_relative "uploads/missing_media_data"
18
+ require_relative "uploads/uploaded_media"
data/sig/manifest.yaml ADDED
@@ -0,0 +1,6 @@
1
+ # The standard libraries the signatures of x-uploads refer to, which rbs collection loads for code that depends on it
2
+ #
3
+ # rbs collection reads every library named here as a standard library, so the gems x-uploads depends on are left to its
4
+ # gemspec, from which it installs their signatures.
5
+ dependencies:
6
+ - name: json
data/sig/x-uploads.rbs ADDED
@@ -0,0 +1,139 @@
1
+ module X
2
+ module Uploads
3
+ VERSION: String
4
+
5
+ def self.gem_version: () -> Gem::Version
6
+
7
+ # Media that has a media key, which names its media identifier, such as the X::Media of x-resources
8
+ interface _MediaKeyed
9
+ def media_key: () -> String?
10
+ end
11
+
12
+ # Media to act on: what an upload returned, media that has a media key, or the identifier of media
13
+ type media = UploadedMedia | Hash[String, untyped] | _MediaKeyed | String | Integer
14
+
15
+ # Media to upload: a path to it, or an IO open on it
16
+ type uploadable = String | _ToPath | _Reader
17
+
18
+ module API
19
+ def upload_media: (uploadable media, ?media_category: (String | Symbol)?, ?alt_text: String?, ?processing_timeout: Numeric?, ?media_type: String?, ?chunk_size: Integer?, ?concurrency: Integer, ?shared: bool?, ?additional_owners: Array[Integer | String]?) -> UploadedMedia
20
+ def chunked_upload_media: (uploadable media, ?media_category: (String | Symbol)?, ?media_type: String?, ?chunk_size: Integer?, ?concurrency: Integer, ?shared: bool?, ?additional_owners: Array[Integer | String]?) -> UploadedMedia
21
+ def await_media_processing: (media media, ?processing_timeout: Numeric?) -> UploadedMedia
22
+ def await_media_processing!: (media media, ?processing_timeout: Numeric?) -> UploadedMedia
23
+ def add_alt_text: (media media, String text) -> UploadedMedia
24
+ def add_subtitles: (media video, media subtitles, String language_code, ?display_name: String?, ?media_category: String | Symbol) -> UploadedMedia
25
+ def update_profile_image: (uploadable media) -> void
26
+ def update_profile_banner: (uploadable media, ?width: Integer?, ?height: Integer?, ?offset_left: Integer?, ?offset_top: Integer?) -> void
27
+ end
28
+
29
+ class Error < ::X::Error
30
+ end
31
+
32
+ module MediaUpload
33
+ AMPLIFY_VIDEO: String
34
+ DM_GIF: String
35
+ DM_IMAGE: String
36
+ DM_VIDEO: String
37
+ SUBTITLES: String
38
+ TWEET_GIF: String
39
+ TWEET_IMAGE: String
40
+ TWEET_VIDEO: String
41
+ DEFAULT_PROCESSING_TIMEOUT: Integer
42
+ DEFAULT_CONCURRENCY: Integer
43
+ DEFAULT_CHUNK_SIZE: Integer
44
+ MAX_CONCURRENCY: Integer
45
+ extend MediaUpload
46
+
47
+ def upload: (uploadable media, client: ::X::Client, ?media_category: (String | Symbol)?, ?alt_text: String?, ?processing_timeout: Numeric?, ?media_type: String?, ?chunk_size: Integer?, ?concurrency: Integer, ?shared: bool?, ?additional_owners: Array[Integer | String]?) -> UploadedMedia
48
+ def chunked_upload: (uploadable media, client: ::X::Client, ?media_category: (String | Symbol)?, ?media_type: String?, ?chunk_size: Integer?, ?concurrency: Integer, ?shared: bool?, ?additional_owners: Array[Integer | String]?) -> UploadedMedia
49
+ def await_processing: (media media, client: ::X::Client, ?processing_timeout: Numeric?) -> UploadedMedia
50
+ def await_processing!: (media media, client: ::X::Client, ?processing_timeout: Numeric?) -> UploadedMedia
51
+ end
52
+
53
+ module Metadata
54
+ extend Metadata
55
+
56
+ def add_alt_text: (media media, String text, client: ::X::Client) -> UploadedMedia
57
+ def add_subtitles: (media video, media subtitles, String language_code, client: ::X::Client, ?display_name: String?, ?media_category: String | Symbol) -> UploadedMedia
58
+ end
59
+
60
+ module Account
61
+ extend Account
62
+
63
+ def update_profile_image: (uploadable media, client: ::X::Client) -> void
64
+ def update_profile_banner: (uploadable media, client: ::X::Client, ?width: Integer?, ?height: Integer?, ?offset_left: Integer?, ?offset_top: Integer?) -> void
65
+ end
66
+ end
67
+
68
+ class UploadedMedia
69
+ attr_reader attrs: Hash[String, untyped]
70
+ alias to_h attrs
71
+
72
+ def initialize: (Hash[String, untyped] attrs) -> void
73
+ def id: () -> Integer
74
+ def media_id: () -> Integer
75
+ def media_key: () -> String?
76
+ def bytesize: () -> Integer?
77
+ def expires_after_secs: () -> Integer?
78
+ def processing_info: () -> Hash[String, untyped]?
79
+ def state: () -> String?
80
+ def check_after_secs: () -> Integer?
81
+ def processing?: () -> bool
82
+ def failed?: () -> bool
83
+ def ready?: () -> bool
84
+ def []: (String key) -> untyped
85
+ def fetch: (String key, *untyped default) ?{ (String key) -> untyped } -> untyped
86
+ def dig: (*String | Integer keys) -> untyped
87
+ def key?: (String key) -> bool
88
+ def as_json: (*untyped) -> Hash[String, untyped]
89
+ def to_json: (?::JSON::State? state) -> String
90
+ def ==: (untyped other) -> bool
91
+ alias eql? ==
92
+ def hash: () -> Integer
93
+ def inspect: () -> String
94
+ def marshal_dump: () -> untyped
95
+ def marshal_load: (untyped state) -> void
96
+ def encode_with: (untyped coder) -> void
97
+ def init_with: (untyped coder) -> void
98
+ end
99
+
100
+ class InvalidMedia < Uploads::Error
101
+ end
102
+
103
+ class InvalidMediaType < InvalidMedia
104
+ end
105
+
106
+ class MissingMediaData < Uploads::Error
107
+ attr_reader problems: Array[Problem]
108
+ def initialize: (?String? message, ?problems: Array[Problem]) -> void
109
+ end
110
+
111
+ class MediaProcessingTimeout < Uploads::Error
112
+ attr_reader media: UploadedMedia?
113
+ attr_reader timeout: Numeric?
114
+ def initialize: (?String? message, ?media: UploadedMedia | Hash[String, untyped] | nil, ?timeout: Numeric?) -> void
115
+ end
116
+
117
+ class MediaProcessingCheckFailed < Uploads::Error
118
+ attr_reader media: UploadedMedia?
119
+ def initialize: (?String? message, ?media: UploadedMedia | Hash[String, untyped] | nil) -> void
120
+ def to_s: () -> String
121
+ end
122
+
123
+ class ChunkedUploadFailed < Uploads::Error
124
+ attr_reader media: UploadedMedia?
125
+ def initialize: (?String? message, ?media: UploadedMedia | Hash[String, untyped] | nil) -> void
126
+ def to_s: () -> String
127
+ end
128
+
129
+ class AltTextFailed < Uploads::Error
130
+ attr_reader media: UploadedMedia?
131
+ def initialize: (?String? message, ?media: UploadedMedia | Hash[String, untyped] | nil) -> void
132
+ def to_s: () -> String
133
+ end
134
+
135
+ class MediaProcessingFailed < Uploads::Error
136
+ attr_reader media: UploadedMedia?
137
+ def initialize: (?String? message, ?media: UploadedMedia | Hash[String, untyped] | nil) -> void
138
+ end
139
+ end