tungsten-runtime 0.1.2

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,185 @@
1
+ # frozen_string_literal: true
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ module Tungsten
5
+ module Runtime
6
+ # Argument paths, sensitivity and the texts built from arguments.
7
+ module Helpers
8
+ module_function
9
+
10
+ # A structural problem that makes a descriptor unusable, or nil.
11
+ def descriptor_problem(op)
12
+ return "`id` must be a non-empty string" if op.id.empty?
13
+ return "every parameter needs a `name` and a `wire`" if op.params.any? { |p| p.name.empty? || p.wire.empty? }
14
+ return "`rpc` needs `field` and `params_field`" if op.rpc && (op.rpc["field"].to_s.empty? || op.rpc["params_field"].to_s.empty?)
15
+
16
+ nil
17
+ end
18
+
19
+ # The path of an argument issue: body.<wire> for a merged body field,
20
+ # body for the whole-body argument, else args.<name>.
21
+ def argument_path(op, path)
22
+ if op.body && !path.empty?
23
+ head = path.first.to_s
24
+ rest = path.drop(1)
25
+ if op.body.arg
26
+ return "body#{Runtime.issue_path(rest)}" if op.body.arg == head
27
+ else
28
+ field = op.body.fields.find { |name, _| name == head }
29
+ return "body.#{field[1]}#{Runtime.issue_path(rest)}" if field
30
+ end
31
+ end
32
+ return "args" if path.empty?
33
+
34
+ "args#{Runtime.issue_path(path)}"
35
+ end
36
+
37
+ def sensitive_request_paths(op) = op.agent["sensitive_request_fields"].reject(&:empty?)
38
+
39
+ # An argument path without array indexes.
40
+ def dotted(path) = path.reject { |s| s.is_a?(Integer) || s.to_s.match?(/\A\d*\z/) }.map(&:to_s).join(".")
41
+
42
+ def sensitive_arg?(op, path)
43
+ d = dotted(path)
44
+ return true if !d.empty? && sensitive_request_paths(op).any? { |f| d == f || d.start_with?("#{f}.") }
45
+
46
+ path.any? { |s| s.is_a?(String) && Util.looks_sensitive?(s) }
47
+ end
48
+
49
+ # The sensitive fields below the argument at path, redacted.
50
+ def redact_below(op, path, value)
51
+ d = dotted(path)
52
+ prefix = d.empty? ? "" : "#{d}."
53
+ below = sensitive_request_paths(op).filter_map { |f| f.delete_prefix(prefix) if f.start_with?(prefix) }
54
+ below.empty? ? value : Util.redact_paths(value, below)
55
+ end
56
+
57
+ # The sensitive argument paths relative to the body as sent; "" is the
58
+ # whole body.
59
+ def sensitive_body_paths(op)
60
+ paths = sensitive_request_paths(op)
61
+ relative = []
62
+ if op.body && op.body.arg.nil?
63
+ paths.each do |p|
64
+ segs = Util.split_path(p)
65
+ next if segs.empty?
66
+
67
+ field = op.body.fields.find { |name, _| name == segs.first }
68
+ next unless field
69
+
70
+ rest = segs.drop(1).join(".")
71
+ relative << (rest.empty? ? field[1] : "#{field[1]}.#{rest}")
72
+ end
73
+ elsif op.body
74
+ arg = op.body.arg
75
+ paths.each do |p|
76
+ if p == arg then relative << ""
77
+ elsif p.start_with?("#{arg}.") then relative << p.delete_prefix("#{arg}.")
78
+ end
79
+ end
80
+ elsif op.rpc
81
+ relative.concat(paths)
82
+ end
83
+ return relative unless op.rpc
84
+
85
+ field = op.rpc["params_field"]
86
+ relative.map { |p| p.empty? ? field : "#{field}.#{p}" }
87
+ end
88
+
89
+ # Every string or number found at a path, through arrays.
90
+ def values_at(value, segments, out, depth = 0)
91
+ return if depth > 64
92
+
93
+ if value.is_a?(Array)
94
+ value.each { |item| values_at(item, segments, out, depth + 1) }
95
+ return
96
+ end
97
+ if segments.empty?
98
+ case value
99
+ when String then out << value
100
+ when Integer, Float then out << Json.number_text(value)
101
+ when Hash then value.each_value { |v| values_at(v, [], out, depth + 1) }
102
+ end
103
+ return
104
+ end
105
+ values_at(value[segments.first], segments.drop(1), out, depth + 1) if value.is_a?(Hash) && value.key?(segments.first)
106
+ end
107
+
108
+ # The arguments with each parameter's value also under its wire name,
109
+ # so references written against the API find arguments whose SDK name
110
+ # differs.
111
+ def with_wire_names(op, args)
112
+ out = args.dup
113
+ op.params.each { |p| out[p.wire] = args[p.name] if args.key?(p.name) && !out.key?(p.wire) }
114
+ out
115
+ end
116
+
117
+ # A path with a leading parameter wire name rewritten to its name.
118
+ def arg_path(op, path)
119
+ return path if path.empty?
120
+
121
+ p = op.params.find { |x| x.wire == path.first && x.name != path.first }
122
+ p ? [p.name] + path.drop(1) : path
123
+ end
124
+
125
+ # {field} references filled from the arguments; sensitive ones are
126
+ # redacted, unset ones shown as <unset>.
127
+ def interpolate(template, args, op)
128
+ template.to_s.gsub(/\{([^{}]+)\}/) do
129
+ segments = arg_path(op, Regexp.last_match(1).strip.split("."))
130
+ value = Util.get_path(args, segments)
131
+ if UNSET.equal?(value) || value.nil?
132
+ "<unset>"
133
+ elsif sensitive_arg?(op, segments)
134
+ REDACTED
135
+ else
136
+ shown = Util.envelope_value(value, false)
137
+ shown.is_a?(String) ? shown : Json.text(shown)
138
+ end
139
+ end
140
+ end
141
+
142
+ PLACEHOLDER = /\A<from step [^<>]+>\z/
143
+
144
+ def placeholders_in(value, out, depth = 0)
145
+ return if depth > 64
146
+
147
+ case value
148
+ when String then out << value if value.match?(PLACEHOLDER)
149
+ when Array then value.each { |v| placeholders_in(v, out, depth + 1) }
150
+ when Hash then value.each_value { |v| placeholders_in(v, out, depth + 1) }
151
+ end
152
+ end
153
+
154
+ def placeholder?(value)
155
+ out = []
156
+ placeholders_in(value, out)
157
+ !out.empty?
158
+ end
159
+
160
+ # The placeholders of args in a preview URL as written instead of
161
+ # percent-encoded.
162
+ def unescape_placeholders(url, args)
163
+ found = []
164
+ placeholders_in(args, found)
165
+ found.uniq.each { |text| url = url.gsub(Serialize.encode_component(text), text) }
166
+ url
167
+ end
168
+
169
+ # " (name value, ...)": the scalar, non-sensitive arguments a call was
170
+ # made with (at most four, by wire name).
171
+ def sent_arguments(op, args)
172
+ shown = []
173
+ args.each do |k, v|
174
+ break if shown.length == 4
175
+ next unless v.is_a?(String) || v.is_a?(Numeric) || [true, false].include?(v)
176
+ next if sensitive_arg?(op, [k])
177
+
178
+ wire = op.params.find { |p| p.name == k }&.wire || k
179
+ shown << "#{wire} #{Util.short_json(v)}"
180
+ end
181
+ shown.empty? ? "" : " (#{shown.join(', ')})"
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ module Tungsten
5
+ module Runtime
6
+ # Keeps the idempotency key of each logical call for the life of the
7
+ # process, so a retry of the same intent reuses it. Any object with
8
+ # `get(scope, logical_id)` and `put(scope, logical_id, key)` can replace
9
+ # it (ClientOptions `idempotency_store`).
10
+ class MemoryIdempotencyStore
11
+ def initialize
12
+ @keys = {}
13
+ @lock = Mutex.new
14
+ end
15
+
16
+ # The key stored for a logical call, or nil.
17
+ def get(scope, logical_id) = @lock.synchronize { @keys[[scope, logical_id]] }
18
+
19
+ def put(scope, logical_id, key)
20
+ @lock.synchronize { @keys[[scope, logical_id]] = key }
21
+ end
22
+ end
23
+
24
+ # Idempotency key formats and confirmation tokens.
25
+ module Keys
26
+ module_function
27
+
28
+ def format_kind(format)
29
+ case format.to_s.downcase.gsub(/[^a-z0-9]/, "")
30
+ when "uuidv4", "uuid4" then "uuid_v4"
31
+ when "uuid" then "uuid"
32
+ when "asciitoken128" then "ascii_token_128"
33
+ else "token"
34
+ end
35
+ end
36
+
37
+ # What a policy's key format expects, in words.
38
+ def format_description(format)
39
+ case format_kind(format)
40
+ when "uuid_v4" then "UUIDv4 string"
41
+ when "uuid" then "UUID string"
42
+ when "ascii_token_128" then "1-128 ASCII letters, digits, dots, underscores or hyphens"
43
+ else "a non-empty printable ASCII string of at most 255 characters"
44
+ end
45
+ end
46
+
47
+ def uuid?(key, v4_only)
48
+ version = v4_only ? "4" : "[1-8]"
49
+ key.is_a?(String) && key.match?(/\A\h{8}-\h{4}-#{version}\h{3}-[89abAB]\h{3}-\h{12}\z/)
50
+ end
51
+
52
+ # The expected format when the key does not match it, else nil.
53
+ def check_format(key, format)
54
+ valid =
55
+ case format_kind(format)
56
+ when "uuid_v4" then uuid?(key, true)
57
+ when "uuid" then uuid?(key, false)
58
+ when "ascii_token_128" then key.is_a?(String) && key.match?(/\A[A-Za-z0-9._-]{1,128}\z/)
59
+ else key.is_a?(String) && !key.empty? && key.bytesize <= 255 && key.match?(/\A[\x21-\x7e]+\z/)
60
+ end
61
+ valid ? nil : format_description(format)
62
+ end
63
+
64
+ # The wire header of an operation's key.
65
+ def header(op)
66
+ h = op.agent["idempotency"]["header"]
67
+ return h if h.is_a?(String) && !h.empty?
68
+
69
+ op.params.find { |p| p.role == "idempotency_key" }&.wire || "Idempotency-Key"
70
+ end
71
+
72
+ # The lifetime of a confirmation token.
73
+ TTL_MS = 5 * 60 * 1000
74
+ PREFIX = "tgc1"
75
+
76
+ # The SHA-256 of the canonical JSON of the arguments.
77
+ def args_digest(args) = Util.sha256_hex(Json.canonical(args))
78
+
79
+ def signature(key, operation, digest, expiry)
80
+ Util.base64url(Util.hmac_sha256(key, "#{operation}\n#{digest}\n#{expiry}"))
81
+ end
82
+
83
+ # tgc1.<expiry ms>.<base64url(HMAC-SHA-256(key, operation, args digest,
84
+ # expiry))>: valid only for the operation and the exact arguments.
85
+ def issue_token(key, operation, args, now)
86
+ expiry = now + TTL_MS
87
+ "#{PREFIX}.#{expiry}.#{signature(key, operation, args_digest(args), expiry)}"
88
+ end
89
+
90
+ # [expiry, signature] of a token of the right shape, else nil.
91
+ def token_expiry(token)
92
+ m = token.to_s.match(/\A#{PREFIX}\.(\d{1,16})\.([A-Za-z0-9_-]{43})\z/o)
93
+ return nil unless m && m[1].to_i.to_s == m[1]
94
+
95
+ [m[1].to_i, m[2]]
96
+ end
97
+
98
+ # :valid, :expired, :mismatch or :malformed.
99
+ def check_token(key, token, operation, args, now)
100
+ parsed = token_expiry(token)
101
+ return :malformed unless parsed
102
+
103
+ expiry, given = parsed
104
+ return :mismatch unless Util.timing_safe_equal?(signature(key, operation, args_digest(args), expiry), given)
105
+
106
+ expiry > now ? :valid : :expired
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,346 @@
1
+ # frozen_string_literal: true
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ require "json"
5
+ require "base64"
6
+
7
+ module Tungsten
8
+ module Runtime
9
+ # The marker of an absent value: an optional argument or member that was
10
+ # not given, a path that leads nowhere. It is neither `nil` (JSON null)
11
+ # nor any JSON value.
12
+ UNSET = Object.new
13
+ def UNSET.inspect = "Tungsten::Runtime::UNSET"
14
+ def UNSET.to_s = "UNSET"
15
+ UNSET.freeze
16
+
17
+ # Binary data: the bytes of a bytes or text body, a multipart file part
18
+ # (with its optional file name and media type) or a binary response.
19
+ # `inspect` never shows the bytes.
20
+ Binary = Data.define(:data, :filename, :content_type) do
21
+ def initialize(data:, filename: nil, content_type: nil)
22
+ bytes = data.to_s.dup.force_encoding(Encoding::BINARY).freeze
23
+ super(data: bytes, filename: filename&.dup&.freeze, content_type: content_type&.dup&.freeze)
24
+ end
25
+
26
+ def inspect = "#<Tungsten::Runtime::Binary #{data.bytesize} bytes>"
27
+ alias_method :to_s, :inspect
28
+
29
+ def bytesize = data.bytesize
30
+ end
31
+
32
+ # JSON values as the runtime handles them: `nil`, `true`, `false`,
33
+ # `String` (UTF-8), `Integer`, `Float`, `Array`, `Hash` (string keys, in
34
+ # member order) and `Binary`. A number without a fractional part and
35
+ # below 2^53 in magnitude is an `Integer`, as every tungsten runtime
36
+ # reads it; numbers are finite.
37
+ module Json
38
+ # 2^53: integers below it in magnitude are exact in every runtime.
39
+ MAX_SAFE = 9_007_199_254_740_992
40
+ INT64_MIN = -(2**63)
41
+ INT64_MAX = (2**63) - 1
42
+ # Bound recursion before parsing or traversing caller-controlled values.
43
+ MAX_DEPTH = 512
44
+
45
+ # Raised when a text is not one JSON document.
46
+ class ParseError < StandardError; end
47
+
48
+ module_function
49
+
50
+ # Parse one JSON document (invalid UTF-8 replaced, a leading byte order
51
+ # mark dropped). Raises ParseError.
52
+ def parse(text)
53
+ source = text.to_s.dup.force_encoding(Encoding::UTF_8)
54
+ source = source.scrub("\uFFFD") unless source.valid_encoding?
55
+ source = source.delete_prefix("\uFEFF")
56
+ normalize(JSON.parse(source, max_nesting: MAX_DEPTH, allow_nan: false, create_additions: false))
57
+ rescue JSON::ParserError, EncodingError, ArgumentError => e
58
+ raise ParseError, e.message
59
+ end
60
+
61
+ # Parse, or UNSET when the text is not JSON.
62
+ def try_parse(text)
63
+ parse(text)
64
+ rescue ParseError
65
+ UNSET
66
+ end
67
+
68
+ # A parsed value with the number rules applied.
69
+ def normalize(value, depth = 0)
70
+ check_depth(depth)
71
+ case value
72
+ when Hash then value.each_with_object({}) { |(k, v), out| out[k] = normalize(v, depth + 1) }
73
+ when Array then value.map { |v| normalize(v, depth + 1) }
74
+ when Float then number(value)
75
+ when Integer then value.between?(INT64_MIN, INT64_MAX) ? value : value.to_f
76
+ else value
77
+ end
78
+ end
79
+
80
+ # A number: an integral float below 2^53 becomes an Integer.
81
+ def number(value)
82
+ return value unless value.is_a?(Float)
83
+ return value if value.nan? || value.infinite?
84
+
85
+ value == value.truncate && value.abs < MAX_SAFE ? value.to_i : value
86
+ end
87
+
88
+ # Convert a Ruby value to a JSON value: symbols to strings, hash keys to
89
+ # strings, models (anything that responds to `to_h` and includes
90
+ # `Tungsten::Runtime::Model`) to their wire form; `UNSET` members and
91
+ # items are dropped. Raises ArgumentError for a value that has no JSON
92
+ # form (an object, a non-finite number).
93
+ def from_ruby(value, depth = 0)
94
+ check_depth(depth)
95
+
96
+ case value
97
+ when nil, true, false, Binary then value
98
+ when String then value.encoding == Encoding::BINARY ? Binary.new(data: value) : utf8(value).dup
99
+ when Symbol then value.to_s
100
+ when Integer then value.between?(INT64_MIN, INT64_MAX) ? value : value.to_f
101
+ when Float
102
+ raise ArgumentError, "a non-finite number is not JSON" if value.nan? || value.infinite?
103
+
104
+ number(value)
105
+ when Hash
106
+ value.each_with_object({}) do |(k, v), out|
107
+ next if UNSET.equal?(v)
108
+
109
+ out[k.to_s.dup] = from_ruby(v, depth + 1)
110
+ end
111
+ when Array then value.reject { |v| UNSET.equal?(v) }.map { |v| from_ruby(v, depth + 1) }
112
+ when Model then from_ruby(value.to_h, depth + 1)
113
+ else
114
+ raise ArgumentError, "#{value.class} has no JSON form"
115
+ end
116
+ end
117
+
118
+ def utf8(text)
119
+ return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
120
+
121
+ text.dup.force_encoding(Encoding::UTF_8).scrub("\uFFFD")
122
+ end
123
+
124
+ # JSON.stringify(value): members in order, no whitespace, numbers as
125
+ # JavaScript writes them; a Binary is its tagged form.
126
+ def text(value)
127
+ out = +""
128
+ write(out, value, false)
129
+ out
130
+ end
131
+
132
+ # The canonical JSON every tungsten runtime digests (confirmation
133
+ # tokens, automatic idempotency keys, content hashes): members sorted
134
+ # by the UTF-16 code units of their names, binary values as
135
+ # {"$bytes": <base64>} or {"$file": {"bytes", "name", "type"}}.
136
+ def canonical(value)
137
+ out = +""
138
+ write(out, value, true)
139
+ out
140
+ end
141
+
142
+ def write(out, value, canonical, depth = 0)
143
+ check_depth(depth)
144
+ case value
145
+ when nil then out << "null"
146
+ when true then out << "true"
147
+ when false then out << "false"
148
+ when Integer, Float then out << number_text(value)
149
+ when String then out << quote(value)
150
+ when Array
151
+ out << "["
152
+ value.each_with_index do |item, i|
153
+ out << "," if i.positive?
154
+ write(out, item, canonical, depth + 1)
155
+ end
156
+ out << "]"
157
+ when Hash
158
+ keys = value.keys.map(&:to_s)
159
+ keys = keys.sort_by { |k| k.encode(Encoding::UTF_16BE).bytes } if canonical
160
+ out << "{"
161
+ keys.each_with_index do |k, i|
162
+ out << "," if i.positive?
163
+ out << quote(k) << ":"
164
+ write(out, value.key?(k) ? value[k] : value[k.to_sym], canonical, depth + 1)
165
+ end
166
+ out << "}"
167
+ when Binary then write_binary(out, value, canonical)
168
+ else write(out, from_ruby(value), canonical, depth + 1)
169
+ end
170
+ end
171
+
172
+ def write_binary(out, bin, canonical)
173
+ encoded = Base64.strict_encode64(bin.data)
174
+ unless canonical
175
+ tagged = { BINARY_KEY => encoded }
176
+ tagged["filename"] = bin.filename if bin.filename
177
+ tagged["content_type"] = bin.content_type if bin.content_type
178
+ return write(out, tagged, false)
179
+ end
180
+ if bin.filename.nil? && bin.content_type.nil?
181
+ out << %({"$bytes":) << quote(encoded) << "}"
182
+ else
183
+ out << %({"$file":{"bytes":) << quote(encoded)
184
+ out << %(,"name":) << (bin.filename ? quote(bin.filename) : "null")
185
+ out << %(,"type":) << (bin.content_type ? quote(bin.content_type) : "null") << "}}"
186
+ end
187
+ end
188
+
189
+ # The member that tags bytes in the JSON text of a Binary.
190
+ BINARY_KEY = "$tungsten_binary"
191
+
192
+ ESCAPES = { '"' => '\\"', "\\" => "\\\\", "\b" => "\\b", "\f" => "\\f", "\n" => "\\n", "\r" => "\\r", "\t" => "\\t" }.freeze
193
+
194
+ # A string literal as JSON.stringify writes it.
195
+ def quote(text)
196
+ body = utf8(text.to_s).gsub(/["\\\u0000-\u001f]/) do |c|
197
+ ESCAPES[c] || format("\\u%04x", c.ord)
198
+ end
199
+ "\"#{body}\""
200
+ end
201
+
202
+ # A number as JavaScript writes it (`String(n)`): 1.0 is "1", 1e-7 is
203
+ # "1e-7", 1e21 is "1e+21".
204
+ def number_text(value)
205
+ return value.to_s if value.is_a?(Integer)
206
+ return "null" if value.nan? || value.infinite?
207
+
208
+ js_number(value)
209
+ end
210
+
211
+ def js_number(value)
212
+ return "0" if value.zero?
213
+
214
+ sign = value.negative? ? "-" : ""
215
+ digits, n = decimal(value.abs)
216
+ k = digits.length
217
+ body =
218
+ if k <= n && n <= 21 then digits + ("0" * (n - k))
219
+ elsif n.positive? && n <= 21 then "#{digits[0, n]}.#{digits[n..]}"
220
+ elsif n > -6 && n <= 0 then "0.#{'0' * -n}#{digits}"
221
+ else
222
+ e = n - 1
223
+ head = k > 1 ? "#{digits[0]}.#{digits[1..]}" : digits
224
+ "#{head}e#{e.negative? ? '-' : '+'}#{e.abs}"
225
+ end
226
+ sign + body
227
+ end
228
+
229
+ # The shortest digits of a positive float and the position of the
230
+ # decimal point (value = 0.digits * 10^n).
231
+ def decimal(value)
232
+ text = value.to_s
233
+ if text.include?("e")
234
+ mantissa, exp = text.split("e")
235
+ whole, frac = mantissa.split(".")
236
+ digits = (whole + frac.to_s).sub(/0+\z/, "")
237
+ n = exp.to_i + whole.length
238
+ else
239
+ whole, frac = text.split(".")
240
+ frac = frac.to_s
241
+ if whole == "0"
242
+ zeros = frac[/\A0*/].length
243
+ digits = frac[zeros..].sub(/0+\z/, "")
244
+ n = -zeros
245
+ else
246
+ digits = (whole + frac).sub(/0+\z/, "")
247
+ n = whole.length
248
+ end
249
+ end
250
+ digits = "0" if digits.empty?
251
+ stripped = digits.sub(/\A0+/, "")
252
+ n -= digits.length - stripped.length
253
+ [stripped.empty? ? "0" : stripped, n]
254
+ end
255
+
256
+ # Structural equality: 1 equals 1.0, member order does not matter.
257
+ def same?(a, b)
258
+ case a
259
+ when Integer, Float then b.is_a?(Numeric) && a == b
260
+ when Array then b.is_a?(Array) && a.length == b.length && a.each_index.all? { |i| same?(a[i], b[i]) }
261
+ when Hash then b.is_a?(Hash) && a.length == b.length && a.all? { |k, v| b.key?(k) && same?(v, b[k]) }
262
+ when Binary then b.is_a?(Binary) && a.data == b.data
263
+ else a == b && a.instance_of?(b.class)
264
+ end
265
+ end
266
+
267
+ # same? where either side may be UNSET.
268
+ def same_opt?(a, b)
269
+ return UNSET.equal?(a) && UNSET.equal?(b) if UNSET.equal?(a) || UNSET.equal?(b)
270
+
271
+ same?(a, b)
272
+ end
273
+
274
+ # A deep copy of a JSON value.
275
+ def copy(value, depth = 0)
276
+ check_depth(depth)
277
+ case value
278
+ when Hash then value.each_with_object({}) { |(k, v), out| out[k.dup] = copy(v, depth + 1) }
279
+ when Array then value.map { |v| copy(v, depth + 1) }
280
+ when String then value.dup
281
+ else value
282
+ end
283
+ end
284
+
285
+ def check_depth(depth)
286
+ raise ArgumentError, "the value is nested too deeply" if depth > MAX_DEPTH
287
+ end
288
+
289
+ # The JSON type of a value: null, boolean, integer, number, string,
290
+ # array, object or binary.
291
+ def type_of(value)
292
+ case value
293
+ when nil then "null"
294
+ when true, false then "boolean"
295
+ when Integer then "integer"
296
+ when Float then "number"
297
+ when String then "string"
298
+ when Array then "array"
299
+ when Hash then "object"
300
+ when Binary then "binary"
301
+ else "value"
302
+ end
303
+ end
304
+
305
+ # The length of a text in UTF-16 code units, the unit JavaScript counts.
306
+ def utf16_length(text)
307
+ text.each_char.sum { |c| c.ord >= 0x10000 ? 2 : 1 }
308
+ end
309
+
310
+ # The first `max` UTF-16 code units of a text, never splitting a
311
+ # character.
312
+ def take_units(text, max)
313
+ used = 0
314
+ out = +""
315
+ text.each_char do |c|
316
+ w = c.ord >= 0x10000 ? 2 : 1
317
+ break if used + w > max
318
+
319
+ used += w
320
+ out << c
321
+ end
322
+ out
323
+ end
324
+
325
+ # The tagged object form of a Binary in arguments (`{"$tungsten_binary":
326
+ # <base64>, "filename"?, "content_type"?}`), as a Binary; UNSET when it
327
+ # is not one.
328
+ def binary_of(value)
329
+ return value if value.is_a?(Binary)
330
+ return UNSET unless value.is_a?(Hash) && value.key?(BINARY_KEY)
331
+
332
+ data = value[BINARY_KEY]
333
+ return UNSET unless data.is_a?(String)
334
+ return UNSET unless (value.keys - [BINARY_KEY, "filename", "content_type"]).empty?
335
+
336
+ name = value["filename"]
337
+ type = value["content_type"]
338
+ return UNSET unless (name.nil? || name.is_a?(String)) && (type.nil? || type.is_a?(String))
339
+
340
+ Binary.new(data: Base64.strict_decode64(data), filename: name, content_type: type)
341
+ rescue ArgumentError
342
+ UNSET
343
+ end
344
+ end
345
+ end
346
+ end