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.
- checksums.yaml +7 -0
- data/.yardopts +8 -0
- data/CHANGELOG.md +171 -0
- data/LICENSE.txt +21 -0
- data/README.md +98 -0
- data/lib/x/uploads/account.rb +100 -0
- data/lib/x/uploads/alt_text_failed.rb +81 -0
- data/lib/x/uploads/api.rb +310 -0
- data/lib/x/uploads/chunked_upload_failed.rb +89 -0
- data/lib/x/uploads/chunks.rb +325 -0
- data/lib/x/uploads/error.rb +25 -0
- data/lib/x/uploads/gif.rb +88 -0
- data/lib/x/uploads/invalid_media.rb +19 -0
- data/lib/x/uploads/invalid_media_type.rb +12 -0
- data/lib/x/uploads/json_classes.rb +14 -0
- data/lib/x/uploads/media_processing_check_failed.rb +85 -0
- data/lib/x/uploads/media_processing_failed.rb +43 -0
- data/lib/x/uploads/media_processing_timeout.rb +55 -0
- data/lib/x/uploads/media_upload.rb +565 -0
- data/lib/x/uploads/metadata.rb +97 -0
- data/lib/x/uploads/missing_media_data.rb +47 -0
- data/lib/x/uploads/multipart.rb +63 -0
- data/lib/x/uploads/signature.rb +104 -0
- data/lib/x/uploads/source.rb +440 -0
- data/lib/x/uploads/uploaded_media.rb +369 -0
- data/lib/x/uploads/utils.rb +330 -0
- data/lib/x/uploads/validator.rb +391 -0
- data/lib/x/uploads/version.rb +24 -0
- data/lib/x/uploads.rb +18 -0
- data/sig/manifest.yaml +6 -0
- data/sig/x-uploads.rbs +139 -0
- metadata +98 -0
|
@@ -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
|