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,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
module Tungsten
|
|
5
|
+
module Runtime
|
|
6
|
+
# Raised when a client cannot be built (a bad base URL, an option of the
|
|
7
|
+
# wrong type). Calls never raise; they return Err.
|
|
8
|
+
class ConfigError < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Defaults of ClientOptions.
|
|
11
|
+
DEFAULT_TIMEOUT_MS = 30_000
|
|
12
|
+
DEFAULT_MAX_EVENT_BYTES = 1024 * 1024
|
|
13
|
+
DEFAULT_MAX_COLLECT_BYTES = 4 * 1024 * 1024
|
|
14
|
+
DEFAULT_MAX_COLLECT_MS = 60_000
|
|
15
|
+
DEFAULT_MAX_RECONNECTS = 3
|
|
16
|
+
DEFAULT_RECONNECT_MAX_MS = 30_000
|
|
17
|
+
|
|
18
|
+
# Retry settings, field by field (nil fields are unset): `max` attempts
|
|
19
|
+
# after the first, `base_ms` and `max_ms` of the backoff, `jitter`
|
|
20
|
+
# (`"none"`, `"full"`, `"equal"`), `honor_retry_after`.
|
|
21
|
+
RetryOptions = Data.define(:max, :base_ms, :max_ms, :jitter, :honor_retry_after) do
|
|
22
|
+
def initialize(max: nil, base_ms: nil, max_ms: nil, jitter: nil, honor_retry_after: nil) = super
|
|
23
|
+
|
|
24
|
+
# From nil, a Hash or RetryOptions.
|
|
25
|
+
def self.from(value)
|
|
26
|
+
case value
|
|
27
|
+
when nil then new
|
|
28
|
+
when RetryOptions then value
|
|
29
|
+
when Hash then new(**value.transform_keys(&:to_sym).slice(:max, :base_ms, :max_ms, :jitter, :honor_retry_after))
|
|
30
|
+
else raise ConfigError, "retries must be a Hash or Tungsten::Runtime::RetryOptions"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Client options (every keyword optional):
|
|
36
|
+
#
|
|
37
|
+
# - `redirects`: `"same_origin_read"` or `"none"`; `"none"` in either the API or options wins;
|
|
38
|
+
# - `base_url`: defaults to the API's first server;
|
|
39
|
+
# - `auth`: credentials by auth scheme name: a String for bearer, API key
|
|
40
|
+
# and basic (`"user:password"`) schemes, a Hash for a composite profile
|
|
41
|
+
# (by cookie name or config key), for OAuth2 client credentials
|
|
42
|
+
# (`client_id`, `client_secret`) or the authorization-code flow
|
|
43
|
+
# (`flow: "authorizationCode"`, `client_id`, `client_secret`,
|
|
44
|
+
# `redirect_uri`);
|
|
45
|
+
# - `token_store`: where authorization-code tokens live (memory by default);
|
|
46
|
+
# - `timeout_ms`: the per-attempt timeout (30 s);
|
|
47
|
+
# - `retries`: RetryOptions over the API's per-tier defaults;
|
|
48
|
+
# - `idempotency_store`: anything with `get(scope, id)` and `put(scope, id,
|
|
49
|
+
# key)` (memory by default);
|
|
50
|
+
# - `middleware`: objects with any of `on_request(ctx)`,
|
|
51
|
+
# `on_response(ctx, response)`, `on_retry(ctx, reason)`;
|
|
52
|
+
# - `validate_responses`: `"warn"` (default), `"strict"` or `"off"`;
|
|
53
|
+
# - `on_diagnostic`: called with warning envelopes;
|
|
54
|
+
# - `headers`: sent on every request;
|
|
55
|
+
# - `max_event_bytes` (1 MiB), `max_collect_bytes` (4 MiB), `max_collect_ms`
|
|
56
|
+
# (60 s), `max_reconnects` (3), `reconnect_max_ms` (30 s),
|
|
57
|
+
# `idle_timeout_ms` (none): event streams;
|
|
58
|
+
# - `confirmation_key`: signs confirmation tokens (random per client);
|
|
59
|
+
# - `now` (epoch milliseconds) and `random` (in [0, 1)): callables for tests.
|
|
60
|
+
ClientOptions = Data.define(
|
|
61
|
+
:redirects, :base_url, :auth, :token_store, :timeout_ms, :retries, :idempotency_store, :middleware,
|
|
62
|
+
:validate_responses, :on_diagnostic, :headers, :max_event_bytes, :max_collect_bytes, :max_collect_ms,
|
|
63
|
+
:max_reconnects, :reconnect_max_ms, :idle_timeout_ms, :confirmation_key, :now, :random
|
|
64
|
+
) do
|
|
65
|
+
def initialize(redirects: nil, base_url: nil, auth: {}, token_store: nil, timeout_ms: nil, retries: nil, idempotency_store: nil,
|
|
66
|
+
middleware: [], validate_responses: "warn", on_diagnostic: nil, headers: {}, max_event_bytes: nil,
|
|
67
|
+
max_collect_bytes: nil, max_collect_ms: nil, max_reconnects: nil, reconnect_max_ms: nil,
|
|
68
|
+
idle_timeout_ms: nil, confirmation_key: nil, now: nil, random: nil)
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Options from nil, a Hash or ClientOptions, with keywords over them.
|
|
73
|
+
def self.from(options = nil, **keywords)
|
|
74
|
+
base =
|
|
75
|
+
case options
|
|
76
|
+
when nil then {}
|
|
77
|
+
when ClientOptions then options.to_h
|
|
78
|
+
when Hash then options.transform_keys(&:to_sym)
|
|
79
|
+
else raise ConfigError, "client options must be a Hash or Tungsten::Runtime::ClientOptions"
|
|
80
|
+
end
|
|
81
|
+
merged = base.merge(keywords.transform_keys(&:to_sym))
|
|
82
|
+
unknown = merged.keys - members
|
|
83
|
+
raise ConfigError, "unknown client option #{unknown.first}" unless unknown.empty?
|
|
84
|
+
|
|
85
|
+
new(**merged)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Options of one call:
|
|
90
|
+
#
|
|
91
|
+
# - `idempotency_key`: required for caller-owned operations that persist
|
|
92
|
+
# a key; generate it once per intent and reuse it on every retry;
|
|
93
|
+
# - `confirm`: `true` (a destructive operation only) or the
|
|
94
|
+
# `confirmation_token` of a preview, bound to the exact arguments;
|
|
95
|
+
# - `refuse_confirm_yes`: `confirm: true` confirms nothing and
|
|
96
|
+
# remediation never offers it (callers that only forward tokens);
|
|
97
|
+
# - `timeout_ms`: the per-attempt timeout;
|
|
98
|
+
# - `verify`: run the operation's verification hook after success;
|
|
99
|
+
# - `headers`: sent with this call.
|
|
100
|
+
CallOptions = Data.define(:idempotency_key, :confirm, :refuse_confirm_yes, :timeout_ms, :verify, :headers) do
|
|
101
|
+
def initialize(idempotency_key: nil, confirm: nil, refuse_confirm_yes: false, timeout_ms: nil, verify: false,
|
|
102
|
+
headers: {})
|
|
103
|
+
super
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Options from nil, a Hash or CallOptions.
|
|
107
|
+
def self.from(value)
|
|
108
|
+
case value
|
|
109
|
+
when nil then new
|
|
110
|
+
when CallOptions then value
|
|
111
|
+
when Hash
|
|
112
|
+
given = value.transform_keys(&:to_sym)
|
|
113
|
+
unknown = given.keys - members
|
|
114
|
+
raise ArgumentError, "unknown call option #{unknown.first}" unless unknown.empty?
|
|
115
|
+
|
|
116
|
+
new(**given)
|
|
117
|
+
else raise ArgumentError, "call options must be a Hash or Tungsten::Runtime::CallOptions"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def key? = idempotency_key.is_a?(String) && !idempotency_key.empty?
|
|
122
|
+
def confirm_yes? = confirm == true
|
|
123
|
+
def token = confirm.is_a?(String) && !confirm.empty? ? confirm : nil
|
|
124
|
+
|
|
125
|
+
# Never shows keys or tokens.
|
|
126
|
+
def inspect
|
|
127
|
+
shown = { idempotency_key: key? ? REDACTED : nil, confirm: confirm == true ? true : (token ? REDACTED : nil),
|
|
128
|
+
timeout_ms: timeout_ms, verify: verify, headers: (headers || {}).keys }
|
|
129
|
+
"#<Tungsten::Runtime::CallOptions #{shown}>"
|
|
130
|
+
end
|
|
131
|
+
alias_method :to_s, :inspect
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# What middleware sees of one attempt; secrets are redacted. `body` is
|
|
135
|
+
# the body as JSON when it is JSON, a text rendering otherwise, UNSET
|
|
136
|
+
# without one.
|
|
137
|
+
RequestContext = Data.define(:operation, :attempt, :http_method, :url, :headers, :body)
|
|
138
|
+
|
|
139
|
+
# What middleware sees of an answer.
|
|
140
|
+
ResponseContext = Data.define(:status, :headers)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
module Tungsten
|
|
5
|
+
module Runtime
|
|
6
|
+
class ClientCore
|
|
7
|
+
# Where a pagination is: the arguments of the next page, the URL of a
|
|
8
|
+
# Link header page, the last cursor seen, and an error to report next.
|
|
9
|
+
PageState = Struct.new(:args, :override_url, :previous_cursor, :done, :pending_error)
|
|
10
|
+
|
|
11
|
+
# Every page of a paginated operation, fetched as the enumeration
|
|
12
|
+
# proceeds: each item is Ok (whose value is a Page) or, last, Err.
|
|
13
|
+
#
|
|
14
|
+
# @return [Enumerator<Ok, Err>]
|
|
15
|
+
def pages(op, args = {}, opts = nil)
|
|
16
|
+
Enumerator.new do |y|
|
|
17
|
+
result = with_options(op, opts) do |d, o|
|
|
18
|
+
state = new_page_state(args)
|
|
19
|
+
loop do
|
|
20
|
+
page = page_step(d, state, o, nil)
|
|
21
|
+
break if page.nil?
|
|
22
|
+
|
|
23
|
+
y << page
|
|
24
|
+
break if page.is_a?(Err)
|
|
25
|
+
end
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
y << result if result.is_a?(Err)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def new_page_state(args)
|
|
35
|
+
converted =
|
|
36
|
+
begin
|
|
37
|
+
Json.from_ruby(args.nil? ? {} : args)
|
|
38
|
+
rescue ArgumentError
|
|
39
|
+
args
|
|
40
|
+
end
|
|
41
|
+
PageState.new(converted.is_a?(Hash) ? converted.dup : converted, nil, UNSET, false, nil)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def set_arg(args, key, value)
|
|
45
|
+
args[key] = value if args.is_a?(Hash)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def arg_at(args, key) = args.is_a?(Hash) ? args.fetch(key, UNSET) : UNSET
|
|
49
|
+
|
|
50
|
+
# The next page (Ok or Err), or nil after the last one.
|
|
51
|
+
def page_step(op, state, opts, claim)
|
|
52
|
+
if state.pending_error
|
|
53
|
+
err = state.pending_error
|
|
54
|
+
state.pending_error = nil
|
|
55
|
+
state.done = true
|
|
56
|
+
return err
|
|
57
|
+
end
|
|
58
|
+
return nil if state.done
|
|
59
|
+
|
|
60
|
+
response = call_with(op, state.args, opts, state.override_url, claim)
|
|
61
|
+
if response.is_a?(Err)
|
|
62
|
+
state.done = true
|
|
63
|
+
return response
|
|
64
|
+
end
|
|
65
|
+
body = response.raw.nil? ? UNSET : response.raw
|
|
66
|
+
pg = op.pagination
|
|
67
|
+
items_field = pg ? pg["items_field"].to_s : ""
|
|
68
|
+
found = items_field.empty? ? body : Util.get_path_str(body, items_field)
|
|
69
|
+
items = found.is_a?(Array) ? found.dup : []
|
|
70
|
+
next_page = nil
|
|
71
|
+
case pg && pg["style"]
|
|
72
|
+
when "cursor"
|
|
73
|
+
usable = ->(v) { !UNSET.equal?(v) && !v.nil? && v != "" }
|
|
74
|
+
cursor = UNSET
|
|
75
|
+
if pg["response_field"].to_s != ""
|
|
76
|
+
v = Util.get_path_str(body, pg["response_field"])
|
|
77
|
+
cursor = v if usable.call(v)
|
|
78
|
+
end
|
|
79
|
+
if UNSET.equal?(cursor) && pg["cursor_item_field"].to_s != "" && !items.empty?
|
|
80
|
+
v = Util.get_path_str(items.last, pg["cursor_item_field"])
|
|
81
|
+
cursor = v if usable.call(v)
|
|
82
|
+
end
|
|
83
|
+
finished = false
|
|
84
|
+
if pg["has_more_field"].to_s != ""
|
|
85
|
+
v = Util.get_path_str(body, pg["has_more_field"])
|
|
86
|
+
finished = v == false
|
|
87
|
+
end
|
|
88
|
+
repeated = !UNSET.equal?(state.previous_cursor) && !UNSET.equal?(cursor) &&
|
|
89
|
+
Json.canonical(state.previous_cursor) == Json.canonical(cursor)
|
|
90
|
+
if !UNSET.equal?(cursor) && !finished && !repeated
|
|
91
|
+
next_page = cursor
|
|
92
|
+
state.previous_cursor = cursor
|
|
93
|
+
set_arg(state.args, op.arg_name(pg["request_param"]), cursor)
|
|
94
|
+
end
|
|
95
|
+
when "offset"
|
|
96
|
+
offset_key = op.arg_name(pg["offset_param"])
|
|
97
|
+
limit = arg_at(state.args, op.arg_name(pg["limit_param"]))
|
|
98
|
+
offset = Util.bounded(arg_at(state.args, offset_key), 0, 0, Util::MAX_SAFE_INTEGER)
|
|
99
|
+
short = limit.is_a?(Numeric) && items.length < limit
|
|
100
|
+
if !items.empty? && !short
|
|
101
|
+
next_page = Util.as_number(offset + items.length)
|
|
102
|
+
set_arg(state.args, offset_key, next_page)
|
|
103
|
+
end
|
|
104
|
+
when "page"
|
|
105
|
+
page_key = op.arg_name(pg["page_param"])
|
|
106
|
+
size = arg_at(state.args, op.arg_name(pg["size_param"]))
|
|
107
|
+
page = Util.bounded(arg_at(state.args, page_key), 1, 0, Util::MAX_SAFE_INTEGER)
|
|
108
|
+
short = size.is_a?(Numeric) && items.length < size
|
|
109
|
+
if !items.empty? && !short
|
|
110
|
+
next_page = Util.as_number(page + 1)
|
|
111
|
+
set_arg(state.args, page_key, next_page)
|
|
112
|
+
end
|
|
113
|
+
when "link_header"
|
|
114
|
+
link = Transport.next_link(response.meta.headers["link"])
|
|
115
|
+
if link
|
|
116
|
+
target = link_target(state.override_url || @base_url, link)
|
|
117
|
+
if target
|
|
118
|
+
state.override_url = target
|
|
119
|
+
next_page = target
|
|
120
|
+
else
|
|
121
|
+
state.pending_error = Runtime.diag(op.id, "UNEXPECTED_RESPONSE").status(response.meta.status)
|
|
122
|
+
.request_id(response.meta.request_id)
|
|
123
|
+
.remedy("The next-page Link header points to another origin; it is not followed so credentials never leave the API's host. Stop paginating here.")
|
|
124
|
+
.attempts(response.meta.attempts).err
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
err = check_items(op, items_field, items, response)
|
|
129
|
+
if err
|
|
130
|
+
state.done = true
|
|
131
|
+
return err
|
|
132
|
+
end
|
|
133
|
+
state.done = true if next_page.nil?
|
|
134
|
+
Ok.new(value: Page.new(items: items, body: UNSET.equal?(body) ? nil : body, next_page: next_page), meta: response.meta)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def link_target(base, link)
|
|
138
|
+
origin = URI.parse(base)
|
|
139
|
+
return nil if origin.scheme.nil?
|
|
140
|
+
|
|
141
|
+
resolved = origin.merge(link)
|
|
142
|
+
same_origin?(origin, resolved) ? resolved.to_s : nil
|
|
143
|
+
rescue URI::Error
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Page items checked with the operation's page item validator.
|
|
148
|
+
def check_items(op, items_field, items, response)
|
|
149
|
+
return nil if op.page_item.nil? || @validate_responses == "off"
|
|
150
|
+
|
|
151
|
+
items.each_with_index do |item, index|
|
|
152
|
+
issues = judge(op.page_item, item)
|
|
153
|
+
next if issues.empty?
|
|
154
|
+
|
|
155
|
+
issue = issues.first
|
|
156
|
+
path = "response"
|
|
157
|
+
path += ".#{items_field}" unless items_field.empty?
|
|
158
|
+
path += "[#{index}]"
|
|
159
|
+
issue.path.each { |seg| path += seg.is_a?(Integer) ? "[#{seg}]" : ".#{seg}" }
|
|
160
|
+
d = Runtime.diag(op.id, "UNEXPECTED_RESPONSE").status(response.meta.status).request_id(response.meta.request_id)
|
|
161
|
+
.param(path).expected(issue.message)
|
|
162
|
+
.remedy("A page item does not match the API description at #{path} (#{issue.message}).")
|
|
163
|
+
.attempts(response.meta.attempts).build
|
|
164
|
+
return Err.new(error: d) if @validate_responses == "strict"
|
|
165
|
+
|
|
166
|
+
emit(d)
|
|
167
|
+
return nil
|
|
168
|
+
end
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
module Tungsten
|
|
5
|
+
module Runtime
|
|
6
|
+
# One validation problem: the path of the offending value (member names
|
|
7
|
+
# and array indexes) and a message.
|
|
8
|
+
Issue = Data.define(:path, :message)
|
|
9
|
+
|
|
10
|
+
# The validator of the runtime schema form of the SDK descriptor
|
|
11
|
+
# document (`tungsten_emit::sdk::descriptors` specifies it): arguments
|
|
12
|
+
# before any network call, success bodies, page items and events after.
|
|
13
|
+
#
|
|
14
|
+
# Issues come structure first, then constraint violations, then unknown
|
|
15
|
+
# members. Types are strict (no coercion); string lengths count UTF-16
|
|
16
|
+
# code units. `pattern` is an ECMAScript expression run on Onigmo, Ruby's
|
|
17
|
+
# engine: `^` and `$` outside a character class are anchored to the
|
|
18
|
+
# whole string (`\A`, `\z`) as in ECMAScript without the `m` flag; other
|
|
19
|
+
# differences (Unicode property and case-folding details, `\d`, `\w` and
|
|
20
|
+
# `\s` being ASCII-only in Ruby) are Onigmo's, and a pattern Onigmo
|
|
21
|
+
# cannot compile is not checked.
|
|
22
|
+
class Schema
|
|
23
|
+
DATE = "(?:(?:[0-9][0-9][2468][048]|[0-9][0-9][13579][26]|[0-9][0-9]0[48]|[02468][048]00" \
|
|
24
|
+
"|[13579][26]00)-02-29|[0-9]{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12][0-9]|3[01])" \
|
|
25
|
+
"|(?:0[469]|11)-(?:0[1-9]|[12][0-9]|30)|(?:02)-(?:0[1-9]|1[0-9]|2[0-8])))"
|
|
26
|
+
IPV4_PART = "(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])"
|
|
27
|
+
IPV6 = ("(?:(?:H:){7}H|(?:H:){1,7}:|(?:H:){1,6}:H|(?:H:){1,5}(?::H){1,2}" \
|
|
28
|
+
"|(?:H:){1,4}(?::H){1,3}|(?:H:){1,3}(?::H){1,4}|(?:H:){1,2}(?::H){1,5}" \
|
|
29
|
+
"|H:(?:(?::H){1,6})|:(?:(?::H){1,7}|:))").gsub("H", "[0-9a-fA-F]{1,4}")
|
|
30
|
+
FORMATS = {
|
|
31
|
+
"uuid" => "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
|
|
32
|
+
"email" => "[^\\s@\"]{1,64}@[^\\s@]{1,255}",
|
|
33
|
+
"date-time" => "#{DATE}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])",
|
|
34
|
+
"date" => DATE,
|
|
35
|
+
"ipv4" => "(?:#{IPV4_PART}\\.){3}#{IPV4_PART}",
|
|
36
|
+
"ipv6" => IPV6
|
|
37
|
+
}.transform_values { |src| Regexp.new("\\A(?:#{src})\\z") }.freeze
|
|
38
|
+
|
|
39
|
+
@patterns = {}
|
|
40
|
+
@patterns_lock = Mutex.new
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# The compiled pattern of an ECMAScript source, or nil when Onigmo
|
|
44
|
+
# cannot compile it.
|
|
45
|
+
def pattern(source)
|
|
46
|
+
@patterns_lock.synchronize do
|
|
47
|
+
return @patterns[source] if @patterns.key?(source)
|
|
48
|
+
|
|
49
|
+
@patterns[source] = begin
|
|
50
|
+
Regexp.new(translate(source))
|
|
51
|
+
rescue RegexpError, ArgumentError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# `^` and `$` outside a character class as `\A` and `\z`.
|
|
58
|
+
def translate(source)
|
|
59
|
+
out = +""
|
|
60
|
+
in_class = false
|
|
61
|
+
escaped = false
|
|
62
|
+
source.each_char do |c|
|
|
63
|
+
if escaped
|
|
64
|
+
out << c
|
|
65
|
+
escaped = false
|
|
66
|
+
elsif c == "\\"
|
|
67
|
+
out << c
|
|
68
|
+
escaped = true
|
|
69
|
+
elsif in_class
|
|
70
|
+
in_class = false if c == "]"
|
|
71
|
+
out << c
|
|
72
|
+
elsif c == "["
|
|
73
|
+
in_class = true
|
|
74
|
+
out << c
|
|
75
|
+
elsif c == "^"
|
|
76
|
+
out << "\\A"
|
|
77
|
+
elsif c == "$"
|
|
78
|
+
out << "\\z"
|
|
79
|
+
else
|
|
80
|
+
out << c
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
out
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A validator of `schema` (a Hash of the runtime schema form) whose
|
|
88
|
+
# `ref`s resolve in `defs`.
|
|
89
|
+
def initialize(schema, defs)
|
|
90
|
+
@schema = schema
|
|
91
|
+
@defs = defs || {}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The issues of a value, none when it is valid.
|
|
95
|
+
def validate(value)
|
|
96
|
+
run(@schema, value)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Whether the value is valid.
|
|
100
|
+
def accepts?(value) = validate(value).empty?
|
|
101
|
+
|
|
102
|
+
def run(schema, value)
|
|
103
|
+
# Descriptors share validators across clients. Keep each validation's
|
|
104
|
+
# issue lists on its own worker, including recursive union probes.
|
|
105
|
+
self.class.new(schema, @defs).send(:run_validation, schema, value)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Whether `schema` admits `value`, in a fresh run (union variants).
|
|
109
|
+
def admits?(schema, value)
|
|
110
|
+
run(schema, value).empty?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def run_validation(schema, value)
|
|
116
|
+
@structure = []
|
|
117
|
+
@constraints = []
|
|
118
|
+
@unknown = []
|
|
119
|
+
check(schema, value, [], 0)
|
|
120
|
+
@structure + @constraints + @unknown
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def bad(path, message) = @structure << Issue.new(path: path, message: message)
|
|
124
|
+
def limit(path, message) = @constraints << Issue.new(path: path, message: message)
|
|
125
|
+
|
|
126
|
+
def describe(value)
|
|
127
|
+
case value
|
|
128
|
+
when nil then "null"
|
|
129
|
+
when true, false then "a boolean"
|
|
130
|
+
when Numeric then "a number"
|
|
131
|
+
when String then "a string"
|
|
132
|
+
when Array then "an array"
|
|
133
|
+
when Hash then "an object"
|
|
134
|
+
when Binary then "binary data"
|
|
135
|
+
else "a value"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def check(schema, value, path, depth)
|
|
140
|
+
return if schema.nil?
|
|
141
|
+
return bad(path, "the value is nested too deeply") if depth > 512
|
|
142
|
+
|
|
143
|
+
case schema["kind"]
|
|
144
|
+
when nil, "any" then nil
|
|
145
|
+
when "never" then bad(path, "no value is allowed here")
|
|
146
|
+
when "nullable" then check(schema["inner"], value, path, depth + 1) unless value.nil?
|
|
147
|
+
when "ref"
|
|
148
|
+
target = @defs[schema["ref"]]
|
|
149
|
+
check(target, value, path, depth + 1) if target
|
|
150
|
+
when "string"
|
|
151
|
+
return bad(path, "expected a string, found #{describe(value)}") unless value.is_a?(String)
|
|
152
|
+
|
|
153
|
+
string_limits(schema, value, path)
|
|
154
|
+
when "bytes"
|
|
155
|
+
bad(path, "expected binary data, found #{describe(value)}") unless value.is_a?(Binary) || value.is_a?(String) || !UNSET.equal?(Json.binary_of(value))
|
|
156
|
+
when "boolean"
|
|
157
|
+
bad(path, "expected a boolean, found #{describe(value)}") unless [true, false].include?(value)
|
|
158
|
+
when "integer" then integer(schema, value, path)
|
|
159
|
+
when "number"
|
|
160
|
+
return bad(path, "expected a number, found #{describe(value)}") unless value.is_a?(Integer) || value.is_a?(Float)
|
|
161
|
+
|
|
162
|
+
number_limits(schema, value, path)
|
|
163
|
+
when "enum"
|
|
164
|
+
values = schema["values"] || []
|
|
165
|
+
bad(path, "must be one of #{Json.text(values)}") unless values.any? { |v| Json.same?(v, value) }
|
|
166
|
+
when "const"
|
|
167
|
+
values = schema["values"] || []
|
|
168
|
+
bad(path, "must be #{Json.text(values[0])}") if !values.empty? && !Json.same?(values[0], value)
|
|
169
|
+
when "array" then array(schema, value, path, depth)
|
|
170
|
+
when "map"
|
|
171
|
+
return bad(path, "expected an object, found #{describe(value)}") unless value.is_a?(Hash)
|
|
172
|
+
|
|
173
|
+
value.each { |k, v| check(schema["items"], v, path + [k], depth + 1) }
|
|
174
|
+
when "object"
|
|
175
|
+
return bad(path, "expected an object, found #{describe(value)}") unless value.is_a?(Hash)
|
|
176
|
+
|
|
177
|
+
object(schema, value, path, depth)
|
|
178
|
+
when "union" then union(schema, value, path, depth)
|
|
179
|
+
when "all" then (schema["variants"] || []).each { |m| check(m, value, path, depth + 1) }
|
|
180
|
+
when "limits"
|
|
181
|
+
case value
|
|
182
|
+
when String then string_limits(schema, value, path)
|
|
183
|
+
when Integer, Float then number_limits(schema, value, path)
|
|
184
|
+
end
|
|
185
|
+
else bad(path, "unknown schema kind #{schema['kind']}")
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def integer(schema, value, path)
|
|
190
|
+
unless value.is_a?(Integer)
|
|
191
|
+
return bad(path, "expected an integer, found #{Json.js_number(value)}") if value.is_a?(Float)
|
|
192
|
+
|
|
193
|
+
return bad(path, "expected an integer, found #{describe(value)}")
|
|
194
|
+
end
|
|
195
|
+
return bad(path, "expected a 32-bit integer") if schema["bits"] == 32 && !value.between?(-(2**31), (2**31) - 1)
|
|
196
|
+
|
|
197
|
+
number_limits(schema, value, path)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def array(schema, value, path, depth)
|
|
201
|
+
return bad(path, "expected an array, found #{describe(value)}") unless value.is_a?(Array)
|
|
202
|
+
|
|
203
|
+
n = value.length
|
|
204
|
+
limit(path, "must have at least #{schema['min_items']} items") if schema["min_items"] && n < schema["min_items"]
|
|
205
|
+
if schema["max_items"] && n > schema["max_items"]
|
|
206
|
+
# Reject an oversized array before traversing or comparing its items.
|
|
207
|
+
return limit(path, "must have at most #{schema['max_items']} items")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
value.each_with_index { |item, i| check(schema["items"], item, path + [i], depth + 1) }
|
|
211
|
+
return unless schema["unique"]
|
|
212
|
+
|
|
213
|
+
seen = {}
|
|
214
|
+
repeated = value.any? do |item|
|
|
215
|
+
bucket = (seen[uniqueness_hash(item)] ||= [])
|
|
216
|
+
duplicate = bucket.any? { |other| Json.same?(item, other) }
|
|
217
|
+
bucket << item unless duplicate
|
|
218
|
+
duplicate
|
|
219
|
+
end
|
|
220
|
+
limit(path, "items must be unique") if repeated
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Hash structurally equal JSON values alike, then confirm matches with
|
|
224
|
+
# same? so hash collisions never turn distinct values into duplicates.
|
|
225
|
+
# Numeric forms and Binary metadata differ from Ruby's Hash equality.
|
|
226
|
+
def uniqueness_hash(value)
|
|
227
|
+
case value
|
|
228
|
+
when Integer, Float then [:number, value.to_f].hash
|
|
229
|
+
when Binary then [:binary, value.data].hash
|
|
230
|
+
when Array then [:array, value.map { |item| uniqueness_hash(item) }].hash
|
|
231
|
+
when Hash then [:object, value.transform_values { |item| uniqueness_hash(item) }].hash
|
|
232
|
+
else [value.class, value].hash
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def object(schema, value, path, depth)
|
|
237
|
+
fields = schema["fields"] || []
|
|
238
|
+
known = {}
|
|
239
|
+
fields.each do |f|
|
|
240
|
+
name = f["name"]
|
|
241
|
+
known[name] = true
|
|
242
|
+
at = path + [name]
|
|
243
|
+
if !value.key?(name)
|
|
244
|
+
bad(at, "missing required member #{name}") if f["required"]
|
|
245
|
+
elsif value[name].nil? && f["nullable"]
|
|
246
|
+
nil
|
|
247
|
+
else
|
|
248
|
+
check(f["schema"], value[name], at, depth + 1)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
value.each do |k, v|
|
|
252
|
+
next if known[k]
|
|
253
|
+
|
|
254
|
+
if schema["extra"]
|
|
255
|
+
check(schema["extra"], v, path + [k], depth + 1)
|
|
256
|
+
elsif schema["additional"] == "closed"
|
|
257
|
+
@unknown << Issue.new(path: path + [k], message: "unknown member #{k}")
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def union(schema, value, path, depth)
|
|
263
|
+
variants = schema["variants"] || []
|
|
264
|
+
if (tag = schema["tag"])
|
|
265
|
+
return bad(path, "expected an object, found #{describe(value)}") unless value.is_a?(Hash)
|
|
266
|
+
|
|
267
|
+
text = value[tag]
|
|
268
|
+
return bad(path + [tag], "missing the discriminator #{tag}") unless text.is_a?(String)
|
|
269
|
+
|
|
270
|
+
tags = schema["tags"] || []
|
|
271
|
+
index = tags.index(text)
|
|
272
|
+
return check(variants[index], value, path, depth + 1) if index && index < variants.length
|
|
273
|
+
|
|
274
|
+
return bad(path + [tag], "must be one of #{Json.text(tags)}")
|
|
275
|
+
end
|
|
276
|
+
return if variants.any? { |v| admits?(v, value) }
|
|
277
|
+
|
|
278
|
+
bad(path, "does not match any variant")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def bound_text(value)
|
|
282
|
+
value.is_a?(Float) && value == value.truncate && value.abs < Json::MAX_SAFE ? value.to_i.to_s : Json.number_text(value)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def string_limits(schema, text, path)
|
|
286
|
+
n = Json.utf16_length(text)
|
|
287
|
+
limit(path, "must have at least #{schema['min_length']} characters") if schema["min_length"] && n < schema["min_length"]
|
|
288
|
+
limit(path, "must have at most #{schema['max_length']} characters") if schema["max_length"] && n > schema["max_length"]
|
|
289
|
+
if (source = schema["pattern"]) && (re = Schema.pattern(source)) && !re.match?(text)
|
|
290
|
+
limit(path, "must match the pattern /#{source}/")
|
|
291
|
+
end
|
|
292
|
+
format = FORMATS[schema["format"]]
|
|
293
|
+
limit(path, "must be a valid #{schema['format']}") if format && !format.match?(text)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def number_limits(schema, number, path)
|
|
297
|
+
limit(path, "must be at least #{bound_text(schema['minimum'])}") if schema["minimum"] && number < schema["minimum"]
|
|
298
|
+
limit(path, "must be at most #{bound_text(schema['maximum'])}") if schema["maximum"] && number > schema["maximum"]
|
|
299
|
+
if schema["exclusive_minimum"] && number <= schema["exclusive_minimum"]
|
|
300
|
+
limit(path, "must be greater than #{bound_text(schema['exclusive_minimum'])}")
|
|
301
|
+
end
|
|
302
|
+
if schema["exclusive_maximum"] && number >= schema["exclusive_maximum"]
|
|
303
|
+
limit(path, "must be less than #{bound_text(schema['exclusive_maximum'])}")
|
|
304
|
+
end
|
|
305
|
+
step = schema["multiple_of"]
|
|
306
|
+
return unless step.is_a?(Numeric) && step != 0
|
|
307
|
+
|
|
308
|
+
q = number.to_f / step
|
|
309
|
+
return unless (q - q.round).abs > 1e-9 * [q.abs, 1].max
|
|
310
|
+
|
|
311
|
+
limit(path, "must be a multiple of #{bound_text(step)}")
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# The path of an issue as .key and [index] segments.
|
|
316
|
+
def self.issue_path(path)
|
|
317
|
+
path.map { |seg| seg.is_a?(Integer) || Util.index?(seg) ? "[#{seg}]" : ".#{seg}" }.join
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|