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.
- checksums.yaml +7 -0
- data/LICENSE +55 -0
- data/README.md +23 -0
- data/lib/tungsten/runtime/auth.rb +258 -0
- data/lib/tungsten/runtime/classify.rb +248 -0
- data/lib/tungsten/runtime/client_core.rb +1147 -0
- data/lib/tungsten/runtime/descriptors.rb +173 -0
- data/lib/tungsten/runtime/dispatcher.rb +83 -0
- data/lib/tungsten/runtime/envelope.rb +230 -0
- data/lib/tungsten/runtime/expr.rb +186 -0
- data/lib/tungsten/runtime/helpers.rb +185 -0
- data/lib/tungsten/runtime/idempotency.rb +110 -0
- data/lib/tungsten/runtime/json.rb +346 -0
- data/lib/tungsten/runtime/macros.rb +343 -0
- data/lib/tungsten/runtime/oauth.rb +329 -0
- data/lib/tungsten/runtime/options.rb +142 -0
- data/lib/tungsten/runtime/pages.rb +173 -0
- data/lib/tungsten/runtime/schema.rb +320 -0
- data/lib/tungsten/runtime/serialize.rb +353 -0
- data/lib/tungsten/runtime/sse.rb +148 -0
- data/lib/tungsten/runtime/stream.rb +500 -0
- data/lib/tungsten/runtime/transport.rb +386 -0
- data/lib/tungsten/runtime/typed.rb +107 -0
- data/lib/tungsten/runtime/util.rb +254 -0
- data/lib/tungsten/runtime/version.rb +9 -0
- data/lib/tungsten/runtime.rb +49 -0
- metadata +82 -0
|
@@ -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
|