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,369 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "x/core"
5
+
6
+ module X
7
+ # Media that was uploaded: the response of an upload, or the status of its processing
8
+ #
9
+ # Both hold the identifier and the media key, and the status of media that X processes, such as a video, holds
10
+ # the state of its processing. The object is frozen, and reads as the Hash it was built from with [], fetch,
11
+ # dig, key?, and to_json, so media["size"] works as it did when an upload returned a Hash.
12
+ #
13
+ # The attributes hold the response as it arrived, so media["id"] is the String X sent, and the media writes itself
14
+ # as JSON with the identifier a String, which a reader of JSON that holds numbers as floats reads whole. The
15
+ # identifier is read as an Integer by id alone, as a resource of the object layer reads its own.
16
+ #
17
+ # @api public
18
+ class UploadedMedia
19
+ # The states of processing that has ended, in success or in failure; in any other, X is still processing the media
20
+ ENDED_STATES = %w[succeeded failed].freeze
21
+ # The attributes that name media, which media known by its identifier or media key alone holds, and nothing more
22
+ IDENTIFYING_KEYS = %w[id media_key].freeze
23
+ # The pattern of a media ID, as a String: one to nineteen digits, as the API takes it and the uploaders check it
24
+ MEDIA_ID = /\A\d{1,19}\z/
25
+ # The message of the error raised for media that holds no identifier
26
+ NO_MEDIA_ID = "attrs must hold the \"id\" of the media, an Integer or a String of 1 to 19 digits, as an upload " \
27
+ "returns it, not %s"
28
+ # The number of the format of the state Marshal writes, which every release of 1.x writes
29
+ #
30
+ # A later release of 1.x adds to the state only what an earlier one ignores, parts after those it reads and keys of a
31
+ # Hash it does not read, so that the state one release of 1.x writes is read by every other, earlier or later.
32
+ MARSHAL_FORMAT = 1
33
+ # The name YAML writes each part of the state under, in the order Marshal writes them
34
+ YAML_KEYS = %w[format attrs].freeze
35
+ # The type of each value of processing information the API documents, which a wait reads as a whole number
36
+ PROCESSING_TYPES = {
37
+ "state" => String, "error" => Hash, "check_after_secs" => ->(wait) { wait.is_a?(String) || (wait.is_a?(Numeric) && wait.finite?) }
38
+ }.freeze
39
+ private_constant :ENDED_STATES, :IDENTIFYING_KEYS, :MEDIA_ID, :NO_MEDIA_ID, :MARSHAL_FORMAT, :YAML_KEYS, :PROCESSING_TYPES
40
+
41
+ # Check whether the data of a response is media the API documents
42
+ #
43
+ # It is an object that holds an identifier an upload can name, and processing information, if any, whose state,
44
+ # wait, and error are of the types the API documents, so that the media can be read and waited for. Anything else,
45
+ # data that is not an object or an identifier of nil among them, is none.
46
+ #
47
+ # Internal to x-uploads: an upload checks the media each response holds with it, called with __send__.
48
+ #
49
+ # @api private
50
+ # @param data [Object] the data of a response
51
+ # @return [Boolean, nil] true if the data is media the API documents, or false or nil if not
52
+ # @example Media whose identifier is nil
53
+ # X::UploadedMedia.__send__(:documented?, {"id" => nil}) # => false
54
+ def self.documented?(data)
55
+ processing = new(data)["processing_info"]
56
+ return true if processing.nil?
57
+
58
+ Hash.try_convert(processing)&.then { |info| PROCESSING_TYPES.all? { |key, type| info[key].then { |value| value.nil? || type === value } } }
59
+ rescue ArgumentError
60
+ false
61
+ end
62
+ private_class_method :documented?
63
+
64
+ # The response data the media was built from
65
+ # @api public
66
+ # @return [Hash{String => Object}] the frozen attributes
67
+ # @example Get the attributes
68
+ # media.attrs # => {"id" => "1880028106020515840", "media_key" => "3_1880028106020515840", ...}
69
+ attr_reader :attrs
70
+ alias_method :to_h, :attrs
71
+
72
+ # Initialize uploaded media
73
+ #
74
+ # The media must hold its identifier under the String key "id", as every upload and status response does, so that
75
+ # the media can be attached to a post, and {id} raises for none.
76
+ #
77
+ # @api public
78
+ # @param attrs [Hash{String => Object}] the data of an upload or status response
79
+ # @return [UploadedMedia] a new, frozen instance
80
+ # @raise [ArgumentError] if the attributes are not a Hash, or hold no "id" that is a media ID the API takes: an
81
+ # Integer that is not negative, or a String of digits alone, of 1 to 19 digits
82
+ # @example Refer to media that was uploaded before
83
+ # X::UploadedMedia.new({"id" => "1880028106020515840"})
84
+ def initialize(attrs)
85
+ @attrs = deep_freeze(Hash.try_convert(attrs) || raise(ArgumentError, "attrs must be a Hash, not #{attrs.inspect}"))
86
+ raise ArgumentError, format(NO_MEDIA_ID, self["id"].inspect) unless media_id?(self["id"])
87
+
88
+ freeze
89
+ end
90
+
91
+ # The numeric media ID, which a post attaches the media by
92
+ #
93
+ # It is the media ID the upload endpoints and a new post take, which {media_id} reads too. The X::Media of
94
+ # x-resources, the media of a post as the object layer reads it, is identified by its media key instead, so its id
95
+ # is the media key, which {media_key} reads here; both classes answer media_id and media_key alike.
96
+ #
97
+ # It is read as strictly as x-resources reads an identifier: an Integer as it is, and a String of digits alone, with
98
+ # no sign, underscore, or whitespace, as a decimal number, so that " 1_0 " is no identifier, rather than 10.
99
+ #
100
+ # @api public
101
+ # @return [Integer] the media ID, whether the response held it as a String or an Integer
102
+ # @example Get the media ID
103
+ # media.id # => 1880028106020515840
104
+ def id
105
+ value = fetch("id")
106
+ value.instance_of?(Integer) ? value : Integer(value, 10)
107
+ end
108
+
109
+ # The numeric media ID, as the X::Media of x-resources names it
110
+ #
111
+ # It is the same as {id}, under the name that reads the same on uploaded media and on the media of a post.
112
+ #
113
+ # @api public
114
+ # @return [Integer] the media ID, whether the response held it as a String or an Integer
115
+ # @example Get the media ID
116
+ # media.media_id # => 1880028106020515840
117
+ def media_id = id
118
+
119
+ # The media key, which names the type of the media and its media ID
120
+ #
121
+ # @api public
122
+ # @return [String, nil] the media key, if the response holds one
123
+ # @example Get the media key
124
+ # media.media_key # => "3_1880028106020515840"
125
+ def media_key = self["media_key"]
126
+
127
+ # The size of the media in bytes
128
+ #
129
+ # @api public
130
+ # @return [Integer, nil] the size, if the response reports it
131
+ # @example Get the size
132
+ # media.bytesize # => 1048576
133
+ def bytesize = self["size"]
134
+
135
+ # The seconds after the response within which the media can be attached to a post
136
+ #
137
+ # X counts them from when it sent the response, which the media does not hold, so they are read as X reported
138
+ # them: media rebuilt from its attributes later, as from JSON it was stored as, holds the seconds of the response
139
+ # it was built from, not those it has left.
140
+ #
141
+ # @api public
142
+ # @return [Integer, nil] the seconds, if the response reports them
143
+ # @example Get the time after which media just uploaded can no longer be attached
144
+ # Time.now + media.expires_after_secs # => 2026-09-19 12:00:00 -0700
145
+ def expires_after_secs = self["expires_after_secs"]
146
+
147
+ # What X reports of the processing of the media
148
+ #
149
+ # @api public
150
+ # @return [Hash{String => Object}, nil] the processing information, or nil for media X does not process
151
+ # @example Get how far X has processed the media
152
+ # media.processing_info&.fetch("progress_percent", nil)
153
+ def processing_info = self["processing_info"]
154
+
155
+ # The state of the processing of the media
156
+ #
157
+ # It is read as X reported it, so a state X adds, which this release knows nothing of, is read as any other.
158
+ #
159
+ # @api public
160
+ # @return [String, nil] pending, in_progress, succeeded, failed, or any other state X reports, or nil for media X
161
+ # does not process
162
+ # @example Get the state
163
+ # media.state # => "succeeded"
164
+ def state = dig("processing_info", "state")
165
+
166
+ # The seconds X asks to wait before checking the processing again
167
+ #
168
+ # @api public
169
+ # @return [Integer, nil] the seconds, if X asks for a wait
170
+ # @example Get the wait
171
+ # media.check_after_secs # => 5
172
+ def check_after_secs = dig("processing_info", "check_after_secs")
173
+
174
+ # Check whether X is still processing the media
175
+ #
176
+ # Media X processes is still processing until its processing ends, which it does in two states alone: succeeded,
177
+ # which {ready?} tells, and failed, which {failed?} tells. In any other it is still processing, whether pending or
178
+ # in_progress, a state X does not document, or no state at all, so that a state X adds between the two it has is
179
+ # waited through as they are, rather than read as a failure. Media X does not process is not processing.
180
+ #
181
+ # @api public
182
+ # @return [Boolean] true if X reports processing of the media that has neither succeeded nor failed
183
+ # @example Check whether a video is still processing
184
+ # media.processing?
185
+ def processing? = !(processing_info.nil? || ENDED_STATES.include?(state))
186
+
187
+ # Check whether the processing of the media failed
188
+ #
189
+ # @api public
190
+ # @return [Boolean] true if the processing failed
191
+ # @example Check whether a video failed to process
192
+ # media.failed?
193
+ def failed? = state.eql?("failed")
194
+
195
+ # Check whether the media can be attached to a post
196
+ #
197
+ # Media whose processing information names no state, or a state X does not document, is not ready, since X has
198
+ # not said that its processing succeeded: it is still processing, as {processing?} tells. Neither is media that
199
+ # holds its identifier and media key alone, as media built from an identifier does, such as the media add_alt_text
200
+ # returns for one, since it holds no response of X to say whether X processes it; await_media_processing checks it.
201
+ #
202
+ # @api public
203
+ # @return [Boolean] true if a response of X holds no processing of the media, or its processing succeeded
204
+ # @example Check whether a video can be posted
205
+ # media.ready?
206
+ def ready? = processing_info.nil? ? !attrs.except(*IDENTIFYING_KEYS).empty? : state.eql?("succeeded")
207
+
208
+ # Read an attribute of the response, as from the Hash an upload used to return
209
+ #
210
+ # @api public
211
+ # @param key [String] the name of the attribute
212
+ # @return [Object, nil] the value, or nil if the response holds none
213
+ # @example Get the identifier
214
+ # media["id"] # => "1880028106020515840"
215
+ def [](key) = attrs[key]
216
+
217
+ # Fetch an attribute of the response, as from the Hash an upload used to return
218
+ #
219
+ # @api public
220
+ # @param key [String] the name of the attribute
221
+ # @param default [Array<Object>] the value to return for an attribute the response does not hold, if any
222
+ # @yieldparam key [String] the name of an attribute the response does not hold
223
+ # @yieldreturn [Object] the value to return in its place
224
+ # @return [Object] the value
225
+ # @raise [KeyError] if the response holds no such attribute and neither a default nor a block is given
226
+ # @example Fetch the identifier
227
+ # media.fetch("id") # => "1880028106020515840"
228
+ # @example Fetch what a response may hold none of
229
+ # media.fetch("processing_info", nil)
230
+ def fetch(key, *default, &) = attrs.fetch(key, *default, &) # steep:ignore UnresolvedOverloading
231
+
232
+ # Read a nested attribute of the response
233
+ #
234
+ # @api public
235
+ # @param keys [Array<String, Integer>] the names that lead to the attribute
236
+ # @return [Object, nil] the value, or nil if the response holds none
237
+ # @example Get the state of the processing
238
+ # media.dig("processing_info", "state") # => "succeeded"
239
+ def dig(*keys) = attrs.dig(*keys)
240
+
241
+ # Check whether the response holds an attribute
242
+ #
243
+ # @api public
244
+ # @param key [String] the name of the attribute
245
+ # @return [Boolean] true if the response holds the attribute, whatever its value
246
+ # @example Check whether X processes the media
247
+ # media.key?("processing_info")
248
+ def key?(key) = attrs.key?(key)
249
+
250
+ # The attributes of the response, as an encoder asks of an object of its own
251
+ #
252
+ # @api public
253
+ # @return [Hash{String => Object}] the frozen attributes
254
+ # @example Store the response of an upload beside a record of it
255
+ # record.update(upload: media.as_json)
256
+ def as_json(*) = attrs
257
+
258
+ # The attributes of the response as JSON
259
+ #
260
+ # Media written into the body of a request is the response it holds, rather than the object itself.
261
+ #
262
+ # @api public
263
+ # @param state [JSON::State, nil] the state the encoder generating the JSON around it passes
264
+ # @return [String] the JSON of the attributes
265
+ # @example Attach the media to a post
266
+ # client.post("tweets", {text: "Look at this cat", media: {media_ids: [media.id.to_s]}})
267
+ def to_json(state = nil) = as_json.to_json(state)
268
+
269
+ # Check whether another object is the same uploaded media
270
+ #
271
+ # @api public
272
+ # @param other [Object] the object to compare
273
+ # @return [Boolean] true if the other object is uploaded media with the same attributes
274
+ # @example Compare media
275
+ # media == X::UploadedMedia.new(media.to_h) # => true
276
+ def ==(other) = other.instance_of?(self.class) && attrs.eql?(other.attrs)
277
+ alias_method :eql?, :==
278
+
279
+ # The hash code of the media, which equal media share
280
+ #
281
+ # @api public
282
+ # @return [Integer] the hash code
283
+ # @example Count the media uploaded
284
+ # uploads.uniq.size
285
+ def hash = [self.class, attrs].hash
286
+
287
+ # Summarize the media for the console
288
+ #
289
+ # @api public
290
+ # @return [String] the class name, identifier, media key, and state
291
+ # @example Inspect media
292
+ # media.inspect # => #<X::UploadedMedia id=1880028106020515840 media_key="3_1880028106020515840" state=nil>
293
+ def inspect = "#<#{self.class} id=#{id} media_key=#{media_key.inspect} state=#{state.inspect}>"
294
+
295
+ # The state Marshal writes
296
+ #
297
+ # What is written is plain data, led by the number of its format, so that media written by one release of 1.x is
298
+ # read by a later one: its attributes, as the response held them.
299
+ #
300
+ # @api public
301
+ # @return [Array(Integer, Hash{String => Object})] the number of the format, then the attributes
302
+ # @example Cache what an upload returned, to attach it later
303
+ # Rails.cache.write("upload", client.upload_media("image.png"))
304
+ def marshal_dump = [MARSHAL_FORMAT, attrs]
305
+
306
+ # Restore media Marshal read, built as the constructor builds it, deep-frozen
307
+ #
308
+ # @api public
309
+ # @param state [Array] the state Marshal wrote
310
+ # @return [void]
311
+ # @raise [UnsupportedFormat] if the state is of a format this release does not read
312
+ # @raise [ArgumentError] if the attributes of the state are not a Hash, or hold no "id" of the media
313
+ # @example Read what an upload returned from a cache
314
+ # Marshal.load(Marshal.dump(media)).media_key
315
+ def marshal_load(state)
316
+ format, attrs = state
317
+ raise UnsupportedFormat, "#{self.class} reads format #{MARSHAL_FORMAT} of Marshal, not #{format.inspect}" unless MARSHAL_FORMAT.eql?(format)
318
+
319
+ initialize(attrs)
320
+ end
321
+
322
+ # Write the state Marshal writes as YAML
323
+ #
324
+ # YAML would write the instance variables of the media, and read them back into one that is not frozen, so it says
325
+ # how it is written: each part of the state Marshal writes, under its name.
326
+ #
327
+ # @api public
328
+ # @param coder [Psych::Coder] the coder YAML writes the media with
329
+ # @return [void]
330
+ # @example Write media as YAML
331
+ # YAML.dump(client.upload_media("image.png"))
332
+ def encode_with(coder) = YAML_KEYS.zip(marshal_dump) { |key, value| coder[key] = value }
333
+
334
+ # Restore media YAML read, frozen, as Marshal restores one
335
+ #
336
+ # @api public
337
+ # @param coder [Psych::Coder] the coder YAML read the media with
338
+ # @return [void]
339
+ # @raise [UnsupportedFormat] if the state is of a format this release does not read
340
+ # @example Read media written as YAML
341
+ # YAML.unsafe_load(YAML.dump(media)).media_key
342
+ def init_with(coder) = marshal_load(coder.map.values_at(*YAML_KEYS))
343
+
344
+ private
345
+
346
+ # Check whether a value is a media ID the API takes
347
+ #
348
+ # It is an Integer, or a String, of 1 to 19 digits alone, so that an Integer that is negative, a String with a
349
+ # sign, an underscore, or whitespace, and anything else whose to_s reads as digits, such as a Symbol, is none.
350
+ #
351
+ # @api private
352
+ # @param value [Object] the value
353
+ # @return [Boolean] true if the value is a media ID the API takes
354
+ def media_id?(value) = (Integer === value || String === value) && MEDIA_ID.match?(value.to_s)
355
+
356
+ # Copy and freeze a value of a response, and what it holds
357
+ # @api private
358
+ # @param value [Object] the value
359
+ # @return [Object] the frozen copy
360
+ def deep_freeze(value)
361
+ case value
362
+ when Hash then value.transform_values { |element| deep_freeze(element) }.freeze
363
+ when Array then value.map { |element| deep_freeze(element) }.freeze
364
+ when String then value.dup.freeze
365
+ else value
366
+ end
367
+ end
368
+ end
369
+ end