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,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