ruby-mcp-client 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- metadata +68 -2
|
@@ -1,43 +1,189 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'uri'
|
|
4
|
+
require_relative 'schema_validator/annotations'
|
|
5
|
+
require_relative 'schema_validator/dialects'
|
|
6
|
+
require_relative 'schema_validator/normalization'
|
|
7
|
+
require_relative 'schema_validator/uri_references'
|
|
8
|
+
require_relative 'schema_validator/references'
|
|
9
|
+
require_relative 'schema_validator/shapes'
|
|
10
|
+
require_relative 'schema_validator/keyword_scan'
|
|
11
|
+
require_relative 'schema_validator/composition'
|
|
12
|
+
require_relative 'schema_validator/evaluation'
|
|
13
|
+
require_relative 'schema_validator/scalars'
|
|
14
|
+
require_relative 'schema_validator/ecma_patterns'
|
|
15
|
+
require_relative 'schema_validator/input_requirements'
|
|
16
|
+
require_relative 'schema_validator/instances'
|
|
17
|
+
|
|
3
18
|
module MCPClient
|
|
4
19
|
# Self-contained JSON Schema validator used to check a tool call result's
|
|
5
|
-
# structuredContent against the tool's declared outputSchema (MCP
|
|
6
|
-
#
|
|
7
|
-
#
|
|
20
|
+
# structuredContent against the tool's declared outputSchema (MCP server/tools
|
|
21
|
+
# spec: "Clients SHOULD validate structured results against this schema";
|
|
22
|
+
# the default schema dialect is JSON Schema 2020-12 per basic "JSON Schema
|
|
23
|
+
# Usage").
|
|
8
24
|
#
|
|
9
|
-
#
|
|
25
|
+
# Supported keywords:
|
|
10
26
|
# - type (single value or array of values), enum, const
|
|
11
27
|
# - properties, required (objects)
|
|
12
|
-
# - items,
|
|
13
|
-
#
|
|
28
|
+
# - items, prefixItems (2020-12) / tuple-form items (draft-07, 2019-09),
|
|
29
|
+
# minItems, maxItems (arrays)
|
|
30
|
+
# - minLength, maxLength, pattern (an ECMA-262 regular expression,
|
|
31
|
+
# translated to the Ruby expression that means the same thing) (strings)
|
|
14
32
|
# - minimum, maximum, exclusiveMinimum, exclusiveMaximum (numbers)
|
|
33
|
+
# - allOf, anyOf, oneOf, not, if/then/else (composition)
|
|
34
|
+
# - $ref to a location inside the same schema document (`#`, `#/$defs/x`,
|
|
35
|
+
# `#/definitions/x`, any JSON pointer, or a plain-name fragment `#name`
|
|
36
|
+
# naming an `$anchor` / a draft-07 `$id: "#name"` of the referencing
|
|
37
|
+
# schema's own resource — a subschema whose `$id` is a URI starts a new
|
|
38
|
+
# resource), with $defs (2019-09, 2020-12) and definitions, which the
|
|
39
|
+
# modern dialects keep as the deprecated spelling of $defs;
|
|
40
|
+
# under draft-07 a $ref replaces its siblings, under 2019-09 and 2020-12
|
|
41
|
+
# it applies alongside them
|
|
42
|
+
# - boolean schemas (true / false), at the root or as subschemas
|
|
43
|
+
#
|
|
44
|
+
# MCP 2026-07-28 rules honoured here:
|
|
45
|
+
# - a schema without `$schema` is 2020-12; the dialects in
|
|
46
|
+
# SUPPORTED_DIALECTS are accepted and any other declared dialect is an
|
|
47
|
+
# error (not a permissive pass); the keyword grammar follows the dialect
|
|
48
|
+
# (DIALECT_KEYWORDS): a keyword the dialect does not define is ignored,
|
|
49
|
+
# neither shape-checked nor reported;
|
|
50
|
+
# - `$ref` (and `$dynamicRef` / `$recursiveRef`) values that do not point
|
|
51
|
+
# inside the document (network URIs, relative documents, urn:, file:)
|
|
52
|
+
# are never dereferenced, and a schema carrying one is rejected rather
|
|
53
|
+
# than treated as permissive;
|
|
54
|
+
# - resource bounds: schema nesting depth, total subschema count, `$ref`
|
|
55
|
+
# chain length, number of nodes visited, number of errors produced and a
|
|
56
|
+
# per-validation time budget. Hitting a bound aborts the validation with
|
|
57
|
+
# one error: an aborted validation never reads as a pass.
|
|
58
|
+
#
|
|
59
|
+
# - multipleOf (numbers), uniqueItems, contains with minContains /
|
|
60
|
+
# maxContains, additionalItems (draft-07, 2019-09) (arrays)
|
|
61
|
+
# - minProperties, maxProperties, patternProperties, additionalProperties,
|
|
62
|
+
# propertyNames, dependentRequired / dependentSchemas (2019-09, 2020-12)
|
|
63
|
+
# and draft-07 dependencies (objects)
|
|
15
64
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
65
|
+
# - unevaluatedItems, unevaluatedProperties at a node that produces every
|
|
66
|
+
# annotation they read: with no in-place applicator beside them (and, for
|
|
67
|
+
# items, no `contains`) they are `additionalItems` / `additionalProperties`
|
|
68
|
+
# over what the node did not name
|
|
69
|
+
#
|
|
70
|
+
# What is left out is exactly what cannot be decided here:
|
|
71
|
+
# unevaluatedItems / unevaluatedProperties where an in-place applicator
|
|
72
|
+
# (allOf, anyOf, oneOf, if, $ref, dependentSchemas — or a `contains`, whose
|
|
73
|
+
# matches annotate the items they matched) contributes annotations
|
|
74
|
+
# collected across a whole composition, the dynamic references
|
|
75
|
+
# ($dynamicRef, $recursiveRef), whose target depends on the dynamic scope a
|
|
76
|
+
# validation was entered through, and the two keywords that only annotate
|
|
77
|
+
# in the default dialect (format, which full validators assert only in
|
|
78
|
+
# format-assertion mode, and contentSchema). Those are ignored rather than
|
|
79
|
+
# misapplied, so validation is best-effort there — it may accept data a
|
|
80
|
+
# full validator would reject, but it does not reject data that conforms to
|
|
81
|
+
# the schema, and an unevaluated keyword is never read as a match for a
|
|
82
|
+
# non-monotonic composition (not, oneOf, if). So that this gap is never
|
|
83
|
+
# silent, {.unsupported_keywords} reports which unapplied validation
|
|
84
|
+
# keywords a schema uses; callers surface them as a warning.
|
|
23
85
|
module SchemaValidator
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
#
|
|
86
|
+
extend Dialects
|
|
87
|
+
extend Normalization
|
|
88
|
+
extend UriReferences
|
|
89
|
+
extend References
|
|
90
|
+
extend Shapes
|
|
91
|
+
extend KeywordScan
|
|
92
|
+
extend Composition
|
|
93
|
+
extend Evaluation
|
|
94
|
+
extend Scalars
|
|
95
|
+
extend EcmaPatterns
|
|
96
|
+
extend InputRequirements
|
|
97
|
+
extend Instances
|
|
98
|
+
|
|
99
|
+
# Raised inside a validation to abandon it (time budget, resource bound).
|
|
100
|
+
class Aborted < StandardError; end
|
|
101
|
+
|
|
102
|
+
# The default dialect (basic "JSON Schema Usage": "When a schema does not
|
|
103
|
+
# include a $schema field, it defaults to JSON Schema 2020-12").
|
|
104
|
+
DEFAULT_DIALECT = 'https://json-schema.org/draft/2020-12/schema'
|
|
105
|
+
DRAFT_2019_09 = 'https://json-schema.org/draft/2019-09/schema'
|
|
106
|
+
DRAFT_07 = 'http://json-schema.org/draft-07/schema'
|
|
107
|
+
|
|
108
|
+
# Dialects this validator accepts. Any other declared dialect is
|
|
109
|
+
# reported as unsupported ("MUST handle unsupported dialects gracefully
|
|
110
|
+
# by returning an appropriate error indicating the dialect is not
|
|
111
|
+
# supported").
|
|
112
|
+
SUPPORTED_DIALECTS = [DEFAULT_DIALECT, DRAFT_2019_09, DRAFT_07].freeze
|
|
113
|
+
|
|
114
|
+
# Resource bounds ("Composition-Keyword Resource Use": implementations
|
|
115
|
+
# SHOULD apply a maximum schema depth, a cap on the total number of
|
|
116
|
+
# subschemas, or a per-validation time budget). A schema comes from the
|
|
117
|
+
# remote server, so all of them apply.
|
|
118
|
+
MAX_SCHEMA_DEPTH = 64
|
|
119
|
+
MAX_SUBSCHEMAS = 2000
|
|
120
|
+
MAX_REF_DEPTH = 32
|
|
121
|
+
MAX_NODE_VISITS = 100_000
|
|
122
|
+
MAX_ERRORS = 100
|
|
123
|
+
MAX_VALUE_INSPECT = 64
|
|
124
|
+
|
|
125
|
+
# How deeply the walk may descend into the instance. The descent into a
|
|
126
|
+
# child value is the walk's only recursion, so this is what drives the
|
|
127
|
+
# interpreter's stack: the instance nests a level per array item or
|
|
128
|
+
# property, and a recursive `$ref` follows it down without bound (the hop
|
|
129
|
+
# budget restarts at every value, so that a recursive schema can describe
|
|
130
|
+
# deep data at all). Counting the descent lets the deepest instance abort
|
|
131
|
+
# with one error like any other exhausted budget.
|
|
132
|
+
#
|
|
133
|
+
# Only a step into a child value — an array item or a property value —
|
|
134
|
+
# counts, and only such a step costs a frame. The schemas a node applies
|
|
135
|
+
# to one value (a `$ref` hop, an allOf/anyOf/oneOf/not/if branch) neither
|
|
136
|
+
# count nor recurse: a recursive schema composed through a few `$defs`
|
|
137
|
+
# mixins applies several of them per instance level, and both counting
|
|
138
|
+
# and recursing on those would scale with the mixin count, so data a peer
|
|
139
|
+
# can legitimately send — its nesting under `JSON.parse`'s default limit
|
|
140
|
+
# of 100 — would abort. {.validate_node} applies them iteratively (see
|
|
141
|
+
# {Evaluation}), bounded by MAX_REF_DEPTH and MAX_NODE_VISITS, which is
|
|
142
|
+
# what already stops a schema recursing on one value forever.
|
|
143
|
+
#
|
|
144
|
+
# The bound therefore sits well above the deepest instance a peer can
|
|
145
|
+
# send, and below the number of levels the smallest stack this library
|
|
146
|
+
# runs on (a transport's reader thread) carries — a figure that no longer
|
|
147
|
+
# depends on how a schema is composed, since what one level costs is now
|
|
148
|
+
# fixed. {.validate} still catches SystemStackError, as a backstop for a
|
|
149
|
+
# stack smaller than any this has been measured on rather than for
|
|
150
|
+
# anything a schema can provoke.
|
|
151
|
+
MAX_NODE_DEPTH = 256
|
|
152
|
+
|
|
153
|
+
# JSON Schema keywords that affect validation but that this validator
|
|
154
|
+
# may not evaluate: the dynamic references where they are dynamic (a
|
|
155
|
+
# target the dynamic scope a validation was entered through could
|
|
156
|
+
# re-bind, which this validator does not track), and the two that only
|
|
157
|
+
# annotate in the default dialect (`format`, asserted by full validators
|
|
158
|
+
# in format-assertion mode, and `contentSchema`). Where one of them is
|
|
159
|
+
# left unevaluated, validation is partial: data may pass here that a full
|
|
160
|
+
# validator would reject.
|
|
161
|
+
#
|
|
162
|
+
# A dynamic reference is not in this position: it is evaluated, against
|
|
163
|
+
# the dynamic scope the validation records as it enters each resource
|
|
164
|
+
# ({References#dynamic_binding}).
|
|
165
|
+
#
|
|
166
|
+
# Every other standard keyword is evaluated — `unevaluatedItems` and
|
|
167
|
+
# `unevaluatedProperties` from the annotations {Evaluation} collects
|
|
168
|
+
# across a whole composition. A standard assertion left unevaluated is
|
|
169
|
+
# not a smaller report but a wrong verdict: it makes a composition
|
|
170
|
+
# branch undecided, and an undecided branch is accepted wherever the
|
|
171
|
+
# composition is monotonic (`allOf`, `anyOf`), so the instance passes a
|
|
172
|
+
# schema that rejects it.
|
|
173
|
+
UNSUPPORTED_KEYWORDS = %w[contentSchema format].freeze
|
|
174
|
+
|
|
175
|
+
# Unsupported keywords that are annotations, not assertions (`format`
|
|
176
|
+
# is annotation-only in the default 2020-12 vocabulary, `contentSchema`
|
|
177
|
+
# only annotates): their presence decides nothing about an instance.
|
|
178
|
+
ANNOTATION_KEYWORDS = %w[format contentSchema].freeze
|
|
179
|
+
|
|
180
|
+
# The references whose target may depend on the dynamic scope.
|
|
181
|
+
DYNAMIC_REFERENCE_KEYWORDS = %w[$dynamicRef $recursiveRef].freeze
|
|
182
|
+
|
|
183
|
+
# Wall-clock budget for a single validate call (pattern matching and the
|
|
184
|
+
# walk itself). Schemas come from the remote server, so an expensive
|
|
185
|
+
# expression or a huge composition must not be able to monopolize the
|
|
186
|
+
# calling thread.
|
|
41
187
|
#
|
|
42
188
|
# The budget is for the whole operation, not per match: a per-match limit
|
|
43
189
|
# multiplies, since the server also controls how many strings it sends
|
|
@@ -48,75 +194,716 @@ module MCPClient
|
|
|
48
194
|
# still makes progress rather than failing every remaining pattern.
|
|
49
195
|
MIN_PATTERN_MATCH_TIMEOUT = 0.01
|
|
50
196
|
|
|
51
|
-
#
|
|
197
|
+
# The longest `pattern` (or `patternProperties` key) a schema may carry.
|
|
198
|
+
# A pattern is translated and compiled before it is matched, and both
|
|
199
|
+
# cost what the peer's text costs; the depth and subschema bounds say
|
|
200
|
+
# nothing about one string, so its length is bounded on its own.
|
|
201
|
+
MAX_PATTERN_LENGTH = 10_000
|
|
202
|
+
|
|
203
|
+
# Keywords whose value is a single subschema to walk (or, for `items`,
|
|
204
|
+
# an array of positional subschemas in draft-07 / 2019-09).
|
|
52
205
|
SUBSCHEMA_KEYWORDS = %w[
|
|
53
|
-
items contains additionalProperties propertyNames not if then else
|
|
54
|
-
unevaluatedItems unevaluatedProperties
|
|
206
|
+
items contains additionalProperties additionalItems propertyNames not if then else
|
|
207
|
+
unevaluatedItems unevaluatedProperties contentSchema
|
|
55
208
|
].freeze
|
|
56
209
|
|
|
57
210
|
# Keywords whose value is a map of name => subschema.
|
|
58
|
-
SUBSCHEMA_MAP_KEYWORDS = %w[properties patternProperties $defs definitions dependentSchemas].freeze
|
|
211
|
+
SUBSCHEMA_MAP_KEYWORDS = %w[properties patternProperties $defs definitions dependentSchemas dependencies].freeze
|
|
59
212
|
|
|
60
213
|
# Keywords whose value is an array of subschemas.
|
|
61
214
|
SUBSCHEMA_ARRAY_KEYWORDS = %w[allOf anyOf oneOf prefixItems].freeze
|
|
62
215
|
|
|
63
|
-
#
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
216
|
+
# Keywords whose value is data, not schema: never walked, never re-keyed.
|
|
217
|
+
DATA_KEYWORDS = %w[enum const default examples].freeze
|
|
218
|
+
|
|
219
|
+
# Keywords that exist only in some dialects, with the dialects that
|
|
220
|
+
# define them. A keyword absent from this table exists in every
|
|
221
|
+
# supported dialect. Under a dialect that does not define a keyword the
|
|
222
|
+
# keyword is an unknown one: ignored, never shape-checked, walked,
|
|
223
|
+
# evaluated or reported as unsupported.
|
|
224
|
+
DIALECT_KEYWORDS = {
|
|
225
|
+
'prefixItems' => [DEFAULT_DIALECT],
|
|
226
|
+
'$dynamicRef' => [DEFAULT_DIALECT],
|
|
227
|
+
'$dynamicAnchor' => [DEFAULT_DIALECT],
|
|
228
|
+
'$recursiveRef' => [DRAFT_2019_09],
|
|
229
|
+
'$recursiveAnchor' => [DRAFT_2019_09],
|
|
230
|
+
'$vocabulary' => [DEFAULT_DIALECT, DRAFT_2019_09],
|
|
231
|
+
'additionalItems' => [DRAFT_2019_09, DRAFT_07],
|
|
232
|
+
'dependencies' => [DRAFT_07],
|
|
233
|
+
'dependentSchemas' => [DEFAULT_DIALECT, DRAFT_2019_09],
|
|
234
|
+
'dependentRequired' => [DEFAULT_DIALECT, DRAFT_2019_09],
|
|
235
|
+
'unevaluatedItems' => [DEFAULT_DIALECT, DRAFT_2019_09],
|
|
236
|
+
'unevaluatedProperties' => [DEFAULT_DIALECT, DRAFT_2019_09],
|
|
237
|
+
'contentSchema' => [DEFAULT_DIALECT, DRAFT_2019_09],
|
|
238
|
+
'deprecated' => [DEFAULT_DIALECT, DRAFT_2019_09],
|
|
239
|
+
'minContains' => [DEFAULT_DIALECT, DRAFT_2019_09],
|
|
240
|
+
'maxContains' => [DEFAULT_DIALECT, DRAFT_2019_09],
|
|
241
|
+
'$anchor' => [DEFAULT_DIALECT, DRAFT_2019_09],
|
|
242
|
+
'$defs' => [DEFAULT_DIALECT, DRAFT_2019_09]
|
|
243
|
+
}.freeze
|
|
244
|
+
|
|
245
|
+
# Plain-name fragment syntax, per dialect: 2020-12 Core Section 8.2.2
|
|
246
|
+
# admits a leading underscore and no colon, while 2019-09 Core Section
|
|
247
|
+
# 8.2.3 and draft-07 Core Section 8.2.3 admit a colon and require a
|
|
248
|
+
# leading letter. A name legal in one dialect is not one in the other,
|
|
249
|
+
# and the dialect in force at the resource decides.
|
|
250
|
+
ANCHOR_NAME_BY_DIALECT = {
|
|
251
|
+
DEFAULT_DIALECT => /\A[A-Za-z_][-A-Za-z0-9._]*\z/,
|
|
252
|
+
DRAFT_2019_09 => /\A[A-Za-z][-A-Za-z0-9.:_]*\z/,
|
|
253
|
+
DRAFT_07 => /\A[A-Za-z][-A-Za-z0-9.:_]*\z/
|
|
254
|
+
}.freeze
|
|
255
|
+
|
|
256
|
+
# The 2020-12 plain-name syntax, the default when no dialect is known.
|
|
257
|
+
ANCHOR_NAME = ANCHOR_NAME_BY_DIALECT.fetch(DEFAULT_DIALECT)
|
|
258
|
+
|
|
259
|
+
# @param name [Object] the candidate plain name
|
|
260
|
+
# @param dialect [String, nil] the canonical dialect in force
|
|
261
|
+
# @return [Boolean] whether the dialect admits the name as a plain name
|
|
262
|
+
def self.anchor_name?(name, dialect)
|
|
263
|
+
name.is_a?(String) && name.match?(ANCHOR_NAME_BY_DIALECT.fetch(dialect, ANCHOR_NAME))
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# @param keyword [String]
|
|
267
|
+
# @param dialect [String, nil] a canonical dialect, or nil for "any"
|
|
268
|
+
# @return [Boolean] whether the dialect defines the keyword
|
|
269
|
+
def self.keyword_known?(keyword, dialect)
|
|
270
|
+
dialect.nil? || !DIALECT_KEYWORDS.key?(keyword) || DIALECT_KEYWORDS[keyword].include?(dialect)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Per-validation state. `scope` is the dynamic scope: the schema
|
|
274
|
+
# resources the evaluation has entered, outermost first, which is what a
|
|
275
|
+
# dynamic reference binds against (JSON Schema 2020-12 Core Section
|
|
276
|
+
# 8.2.3.2).
|
|
277
|
+
Context = Struct.new(:root, :deadline, :dialect, :visits, :errors, :speculative, :anchors, :undecided, :depth,
|
|
278
|
+
:scope, keyword_init: true)
|
|
279
|
+
|
|
280
|
+
# Per-preflight state: the document, the counter the walk accounts to,
|
|
281
|
+
# the problems it found, and the positions it has still to read (a
|
|
282
|
+
# stack, so the walk needs no interpreter frame of its own).
|
|
283
|
+
Walk = Struct.new(:root, :counter, :problems, :pending, keyword_init: true)
|
|
284
|
+
|
|
285
|
+
# Raised while normalizing a schema whose structure exceeds the bounds,
|
|
286
|
+
# so a peer-supplied document is never copied whole.
|
|
287
|
+
class TooLarge < StandardError; end
|
|
288
|
+
|
|
289
|
+
# Structural elements (schemas, the keyword maps holding them, and array
|
|
290
|
+
# members — boolean subschemas included) a schema document may contain
|
|
291
|
+
# before it is rejected unread. A usable schema has at most
|
|
292
|
+
# MAX_SUBSCHEMAS subschemas, each with a handful of keyword maps at
|
|
293
|
+
# most, so this bound only ever stops documents the preflight would
|
|
294
|
+
# reject anyway.
|
|
295
|
+
MAX_STRUCTURAL_OBJECTS = MAX_SUBSCHEMAS * 4
|
|
296
|
+
|
|
297
|
+
# Check that a schema can be used at all: it is an object or a boolean,
|
|
298
|
+
# its dialect is supported, it stays within the resource bounds, and
|
|
299
|
+
# every `$ref` resolves inside the document to a schema (a network or
|
|
300
|
+
# otherwise external reference is never dereferenced and makes the
|
|
301
|
+
# schema unusable rather than permissive).
|
|
302
|
+
# The check runs under a deadline like a validation does: the schema is
|
|
303
|
+
# the peer's, and reading it (translating and compiling its patterns
|
|
304
|
+
# above all) costs what the peer's text costs.
|
|
305
|
+
# @param schema [Object] the schema (string or symbol keys)
|
|
306
|
+
# @param counter [Hash] filled in with the preflight state, so a caller
|
|
307
|
+
# can tell one kind of problem from another (`:unsupported_dialect`)
|
|
308
|
+
# @param deadline [Float, nil] monotonic deadline for the whole check
|
|
309
|
+
# (PATTERN_MATCH_TIMEOUT from now when none is given)
|
|
310
|
+
# @return [Array<String>] problems (empty when the schema is usable)
|
|
311
|
+
def self.check_schema(schema, counter = {}, deadline: nil)
|
|
312
|
+
counter[:deadline] = deadline || (Process.clock_gettime(Process::CLOCK_MONOTONIC) + PATTERN_MATCH_TIMEOUT)
|
|
313
|
+
check_normalized(normalize_schema(schema, deadline: counter[:deadline]), counter)
|
|
314
|
+
rescue TooLarge => e
|
|
315
|
+
[e.message]
|
|
316
|
+
rescue Aborted => e
|
|
317
|
+
["validation aborted: #{e.message}"]
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# The dialect a schema declares (at its root or at an embedded resource
|
|
321
|
+
# root) that this validator does not implement. MCP 2026-07-28 basic
|
|
322
|
+
# "Implementation Requirements": a client "MUST handle unsupported
|
|
323
|
+
# dialects gracefully by returning an appropriate error indicating the
|
|
324
|
+
# dialect is not supported", which a caller can only do once it can tell
|
|
325
|
+
# an unsupported dialect from every other reason a schema is unusable.
|
|
326
|
+
# @param schema [Object] the schema (string or symbol keys)
|
|
327
|
+
# @return [String, nil] the declared dialect, or nil when every dialect
|
|
328
|
+
# the document declares is supported
|
|
329
|
+
def self.unsupported_dialect(schema)
|
|
330
|
+
state = {}
|
|
331
|
+
check_schema(schema, state)
|
|
332
|
+
state[:unsupported_dialect]
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# A bounded, string-keyed copy of a schema (booleans pass through).
|
|
336
|
+
# @param schema [Object]
|
|
337
|
+
# @param deadline [Float, nil] monotonic deadline the copy runs under
|
|
338
|
+
# @return [Object]
|
|
339
|
+
# @raise [TooLarge] when the document exceeds MAX_STRUCTURAL_OBJECTS
|
|
340
|
+
# @raise [Aborted] when the deadline passed during the copy
|
|
341
|
+
def self.normalize_schema(schema, deadline: nil)
|
|
342
|
+
schema.is_a?(Hash) ? deep_stringify(schema, 0, { objects: 0, deadline: deadline }) : schema
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# {.check_schema} on an already normalized schema.
|
|
346
|
+
# @param counter [Hash] filled in with the preflight state (the memoized
|
|
347
|
+
# anchor index included), so a caller going on to validate reads the
|
|
348
|
+
# document the way the preflight read it
|
|
349
|
+
# @return [Array<String>] problems
|
|
350
|
+
def self.check_normalized(root, counter = {})
|
|
351
|
+
return [] if [true, false].include?(root)
|
|
352
|
+
return ["schema must be an object or a boolean, got #{json_type(root)}"] unless root.is_a?(Hash)
|
|
353
|
+
|
|
354
|
+
declared = dialect(root)
|
|
355
|
+
return ['$schema must be a non-empty string naming the dialect'] if declared.nil?
|
|
356
|
+
|
|
357
|
+
unless supported_dialect?(declared)
|
|
358
|
+
counter[:unsupported_dialect] = declared
|
|
359
|
+
return ["schema dialect #{clip(declared.inspect)} is not supported " \
|
|
360
|
+
"(supported: #{SUPPORTED_DIALECTS.join(', ')})"]
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
problems = []
|
|
364
|
+
counter.update(count: 0, dialect: canonical_dialect(declared), walked: {}.compare_by_identity,
|
|
365
|
+
depths: lexical_depths(root, canonical_dialect(declared)))
|
|
366
|
+
walk_schema(root, root, 0, counter, problems)
|
|
367
|
+
exhausted = problems.empty? && budget_exhausted?(counter[:deadline])
|
|
368
|
+
problems << 'validation aborted: validation time budget exhausted during the schema check' if exhausted
|
|
369
|
+
problems.concat(anchor_index_problems(root, counter)) if problems.empty?
|
|
370
|
+
problems.uniq
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Problems the anchor index reveals: anchor names must be unique within
|
|
374
|
+
# a schema resource (JSON Schema 2020-12 Core Section 8.2.2) and a
|
|
375
|
+
# resource URI unique within the document (Section 9.1.2) — either
|
|
376
|
+
# declared twice would bind a reference to whichever declaration the
|
|
377
|
+
# walk met first — and an index that stopped at its bound before
|
|
378
|
+
# reaching every object leaves references and resource dialects
|
|
379
|
+
# undecided, so either makes the schema unusable.
|
|
380
|
+
# @param resolver [Hash] holder of the memoized anchor index
|
|
381
|
+
# @return [Array<String>]
|
|
382
|
+
def self.anchor_index_problems(root, resolver)
|
|
383
|
+
index = (resolver[:anchors] ||= anchor_index(root, resolver[:dialect]))
|
|
384
|
+
problems = index[:duplicates].map do |name|
|
|
385
|
+
"anchor #{clip(name.inspect)} is declared more than once in a schema resource"
|
|
386
|
+
end
|
|
387
|
+
problems.concat(index[:duplicate_ids].uniq.map do |base|
|
|
388
|
+
"schema resource #{clip(base.inspect)} is declared more than once"
|
|
389
|
+
end)
|
|
390
|
+
if index[:truncated]
|
|
391
|
+
problems << 'schema has too many or too deeply nested objects to index its anchors ' \
|
|
392
|
+
"(more than #{MAX_SUBSCHEMAS}, or deeper than #{MAX_SCHEMA_DEPTH})"
|
|
393
|
+
end
|
|
394
|
+
problems
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Walk every subschema position, checking bounds and references. A
|
|
398
|
+
# schema object is walked once, however many positions or references
|
|
399
|
+
# lead to it, so a recursive schema stays within the bounds. The dialect
|
|
400
|
+
# follows the resource: an embedded resource declaring `$schema` is
|
|
401
|
+
# walked under its own dialect.
|
|
402
|
+
# @return [void]
|
|
403
|
+
def self.walk_schema(schema, root, depth, counter, problems, dialect = counter[:dialect])
|
|
404
|
+
walk = Walk.new(root: root, counter: counter, problems: problems,
|
|
405
|
+
pending: [[schema, depth, dialect]])
|
|
406
|
+
until walk.pending.empty?
|
|
407
|
+
return unless problems.empty?
|
|
408
|
+
|
|
409
|
+
walk_position(walk, *walk.pending.pop)
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Walk one schema position and queue the positions it leads to. The
|
|
414
|
+
# queue is a stack and children are pushed in reverse, so a document is
|
|
415
|
+
# read depth-first and in document order, exactly as a recursive walk
|
|
416
|
+
# would read it — but a `$ref` chain hundreds of schemas long costs
|
|
417
|
+
# queue entries rather than interpreter frames, so a shallow document
|
|
418
|
+
# whose references chain deeply can no longer overflow the (small) stack
|
|
419
|
+
# of the thread a transport reads on. A reference is followed before the
|
|
420
|
+
# position's own subschemas, so it is pushed last.
|
|
421
|
+
# @return [void]
|
|
422
|
+
def self.walk_position(walk, schema, depth, dialect)
|
|
423
|
+
return unless schema_value?(schema)
|
|
424
|
+
# The positions are as many as the peer's document holds, and each
|
|
425
|
+
# may cost a pattern's translation: the deadline is consulted before
|
|
426
|
+
# every one, and a check past it stops there ({.check_normalized}
|
|
427
|
+
# reports it).
|
|
428
|
+
return if budget_exhausted?(walk.counter[:deadline])
|
|
429
|
+
return unless admit_schema?(schema, depth, walk.counter, walk.problems)
|
|
430
|
+
|
|
431
|
+
if resource_root?(schema, dialect) && schema.key?('$schema')
|
|
432
|
+
problem = embedded_dialect_problem(schema)
|
|
433
|
+
return record_embedded_dialect_problem(walk, schema, problem) if problem
|
|
434
|
+
|
|
435
|
+
dialect = embedded_dialect(schema, dialect)
|
|
436
|
+
elsif schema.key?('$schema') && !schema.equal?(walk.root) && !resource_start?(schema)
|
|
437
|
+
# A `$schema` is read at a schema resource root only (JSON Schema
|
|
438
|
+
# 2020-12 Core Section 8.1.1); anywhere else it is a malformed
|
|
439
|
+
# keyword, not an absent one.
|
|
440
|
+
walk.problems << '$schema is only allowed at a schema resource root (the document root, or a ' \
|
|
441
|
+
'subschema declaring an $id)'
|
|
442
|
+
return
|
|
443
|
+
end
|
|
444
|
+
referenced = schema.key?('$ref') ? check_ref(walk, schema, depth, dialect) : []
|
|
445
|
+
# draft-07: the $ref replaces its siblings, so the applicators next to
|
|
446
|
+
# it are never applied and are not preflighted either; definitions are
|
|
447
|
+
# a bag of reusable schemas, not applicators, and stay reachable
|
|
448
|
+
# through references.
|
|
449
|
+
return queue_definitions(walk, schema, depth, dialect, referenced) if dialect == DRAFT_07 && schema.key?('$ref')
|
|
450
|
+
|
|
451
|
+
referenced.concat(check_dynamic_refs(walk, schema, depth, dialect))
|
|
452
|
+
check_keyword_shapes(walk, schema, dialect)
|
|
453
|
+
queue_subschemas(walk, schema, depth, dialect, referenced)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# @return [void]
|
|
457
|
+
def self.record_embedded_dialect_problem(walk, schema, problem)
|
|
458
|
+
declared = dialect(schema)
|
|
459
|
+
walk.counter[:unsupported_dialect] ||= declared if declared && !supported_dialect?(declared)
|
|
460
|
+
walk.problems << problem
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Queue the reusable schemas beside a draft-07 `$ref`, then what the
|
|
464
|
+
# reference reaches (walked first, so pushed last).
|
|
465
|
+
# @return [void]
|
|
466
|
+
def self.queue_definitions(walk, schema, depth, dialect, referenced)
|
|
467
|
+
positions = []
|
|
468
|
+
each_definition(schema, dialect) { |sub| positions << [sub, depth + 1, dialect] }
|
|
469
|
+
queue_positions(walk, positions, referenced)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Queue every subschema position under a schema object, then what its
|
|
473
|
+
# reference reaches.
|
|
474
|
+
# @return [void]
|
|
475
|
+
def self.queue_subschemas(walk, schema, depth, dialect, referenced)
|
|
476
|
+
positions = []
|
|
477
|
+
each_subschema(schema, dialect) { |sub| positions << [sub, depth + 1, dialect] }
|
|
478
|
+
queue_positions(walk, positions, referenced)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# @return [void]
|
|
482
|
+
def self.queue_positions(walk, positions, referenced)
|
|
483
|
+
walk.pending.concat(positions.reverse)
|
|
484
|
+
walk.pending.concat(referenced.reverse)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Every check a schema object's own keywords get.
|
|
78
488
|
# @return [void]
|
|
79
|
-
def self.
|
|
80
|
-
|
|
489
|
+
def self.check_keyword_shapes(walk, schema, dialect)
|
|
490
|
+
problems = walk.problems
|
|
491
|
+
if dialect == DEFAULT_DIALECT && schema['items'].is_a?(Array)
|
|
492
|
+
problems << 'items must be a schema in JSON Schema 2020-12 (positional schemas go in prefixItems)'
|
|
493
|
+
end
|
|
494
|
+
check_applicator_shapes(schema, dialect, problems)
|
|
495
|
+
check_assertion_shapes(schema, dialect, problems)
|
|
496
|
+
check_exclusive_bounds(schema, dialect, problems)
|
|
497
|
+
check_identifier_shapes(schema, dialect, problems)
|
|
498
|
+
check_core_keyword_shapes(schema, dialect, problems)
|
|
499
|
+
check_pattern_shapes(schema, dialect, problems, walk.counter[:deadline])
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Account for a schema (object or boolean) about to be walked: once per
|
|
503
|
+
# object, and within the subschema and nesting bounds — a boolean is a
|
|
504
|
+
# subschema too and obeys the depth bound.
|
|
505
|
+
# @return [Boolean] whether the object's keywords are to be walked
|
|
506
|
+
def self.admit_schema?(schema, depth, counter, problems)
|
|
507
|
+
return false if schema.is_a?(Hash) && counter[:walked].key?(schema)
|
|
508
|
+
|
|
509
|
+
counter[:walked][schema] = true if schema.is_a?(Hash)
|
|
510
|
+
counter[:count] += 1
|
|
511
|
+
if counter[:count] > MAX_SUBSCHEMAS
|
|
512
|
+
problems << "schema has more than #{MAX_SUBSCHEMAS} subschemas"
|
|
513
|
+
return false
|
|
514
|
+
end
|
|
515
|
+
if depth > MAX_SCHEMA_DEPTH
|
|
516
|
+
problems << "schema nesting depth exceeds #{MAX_SCHEMA_DEPTH}"
|
|
517
|
+
return false
|
|
518
|
+
end
|
|
519
|
+
schema.is_a?(Hash)
|
|
520
|
+
end
|
|
81
521
|
|
|
82
|
-
|
|
83
|
-
|
|
522
|
+
# Yield every subschema directly under a schema object (skipping the
|
|
523
|
+
# keywords the dialect does not define; nil applies no dialect).
|
|
524
|
+
# @return [void]
|
|
525
|
+
def self.each_subschema(schema, dialect = nil, &block)
|
|
84
526
|
schema.each do |keyword, value|
|
|
85
|
-
if
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
527
|
+
next if DATA_KEYWORDS.include?(keyword) || !keyword_known?(keyword, dialect)
|
|
528
|
+
|
|
529
|
+
subschemas_under(keyword, value).each(&block)
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# The subschema positions one keyword holds.
|
|
534
|
+
# @return [Array<Object>]
|
|
535
|
+
def self.subschemas_under(keyword, value)
|
|
536
|
+
if SUBSCHEMA_KEYWORDS.include?(keyword)
|
|
537
|
+
value.is_a?(Array) ? value : [value]
|
|
538
|
+
elsif keyword == 'dependencies'
|
|
539
|
+
# Property-name arrays are data; only the schema entries are walked.
|
|
540
|
+
value.is_a?(Hash) ? value.values.select { |v| schema_value?(v) } : []
|
|
541
|
+
elsif SUBSCHEMA_MAP_KEYWORDS.include?(keyword)
|
|
542
|
+
value.is_a?(Hash) ? value.values : []
|
|
543
|
+
elsif SUBSCHEMA_ARRAY_KEYWORDS.include?(keyword)
|
|
544
|
+
value.is_a?(Array) ? value : []
|
|
545
|
+
else
|
|
546
|
+
[]
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# Yield the reusable schemas under the definition bag(s) the dialect
|
|
551
|
+
# defines: `definitions` everywhere (2020-12 Validation Appendix A keeps
|
|
552
|
+
# it as the deprecated spelling of `$defs`) and `$defs` in 2019-09 and
|
|
553
|
+
# 2020-12; both when no dialect is given.
|
|
554
|
+
# @return [void]
|
|
555
|
+
def self.each_definition(schema, dialect = nil, &block)
|
|
556
|
+
%w[$defs definitions].each do |keyword|
|
|
557
|
+
next unless keyword_known?(keyword, dialect)
|
|
558
|
+
|
|
559
|
+
schema[keyword].each_value(&block) if schema[keyword].is_a?(Hash)
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Check the `$ref` of a schema object and queue what it reaches: a
|
|
564
|
+
# pointer may lead into a bag the dialect does not walk (`$defs` under
|
|
565
|
+
# draft-07), and what a reference applies must be usable too.
|
|
566
|
+
# @return [Array<Array>] the positions the reference reaches
|
|
567
|
+
def self.check_ref(walk, schema, depth, dialect, keyword = '$ref')
|
|
568
|
+
root = walk.root
|
|
569
|
+
counter = walk.counter
|
|
570
|
+
problems = walk.problems
|
|
571
|
+
ref = schema[keyword]
|
|
572
|
+
unless ref.is_a?(String)
|
|
573
|
+
problems << "#{keyword} must be a string, got #{json_type(ref)}"
|
|
574
|
+
return []
|
|
575
|
+
end
|
|
576
|
+
if external_ref?(ref, root, counter[:dialect], counter, from: schema)
|
|
577
|
+
problems << "external #{keyword} #{clip(ref.inspect)} is not dereferenced (only references inside the " \
|
|
578
|
+
'schema document are resolved; network $ref resolution is disabled)'
|
|
579
|
+
return []
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
positions = []
|
|
583
|
+
problem = ref_chain_problem(ref, root, counter[:dialect], counter, from: schema) do |target, hop, from|
|
|
584
|
+
position = referenced_target_position(walk, target, hop, from, depth, dialect)
|
|
585
|
+
positions << position if position
|
|
586
|
+
end
|
|
587
|
+
problems << problem.sub('$ref', keyword) if problem
|
|
588
|
+
positions
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# The position a reference's target is walked at: under its own
|
|
592
|
+
# resource's dialect and at its own lexical depth. A boolean target
|
|
593
|
+
# needs no preflight and is charged once per distinct position (not per
|
|
594
|
+
# reference), and that position still obeys the depth bound.
|
|
595
|
+
# @return [Array, nil] the position to walk, or nil for a boolean target
|
|
596
|
+
def self.referenced_target_position(walk, target, hop, from, depth, dialect)
|
|
597
|
+
counter = walk.counter
|
|
598
|
+
position, opaque, visited = pointer_position(hop, walk.root, counter[:dialect], counter, from)
|
|
599
|
+
unless target.is_a?(Hash)
|
|
600
|
+
walk.problems << "schema nesting depth exceeds #{MAX_SCHEMA_DEPTH}" if position && position > MAX_SCHEMA_DEPTH
|
|
601
|
+
# A boolean in a position the walk visits was admitted there; one
|
|
602
|
+
# the walk never reaches (behind an opaque keyword, or beside a
|
|
603
|
+
# draft-07 $ref) is charged here, once per position.
|
|
604
|
+
charge_boolean_target(hop, walk.root, from, counter, walk.problems) unless visited
|
|
605
|
+
return nil
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
lexical = (counter[:depths] || {})[target]
|
|
609
|
+
target_depth = if opaque
|
|
610
|
+
[position, lexical].compact.max || (depth + 1)
|
|
611
|
+
else
|
|
612
|
+
lexical || position || (depth + 1)
|
|
613
|
+
end
|
|
614
|
+
[target, target_depth, indexed_dialect(target, counter) || dialect]
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# Count a boolean a reference reaches toward the subschema bound, once
|
|
618
|
+
# per distinct position (resource and decoded pointer): a document may
|
|
619
|
+
# not hide thousands of applied schemas behind pointer-addressable
|
|
620
|
+
# booleans.
|
|
621
|
+
# @return [void]
|
|
622
|
+
def self.charge_boolean_target(hop, root, from, counter, problems)
|
|
623
|
+
index = (counter[:anchors] ||= anchor_index(root, counter[:dialect]))
|
|
624
|
+
resource, hop = pointer_origin(index, root, hop, from)
|
|
625
|
+
return unless resource
|
|
626
|
+
|
|
627
|
+
tokens = pointer_tokens(hop)
|
|
628
|
+
return unless tokens
|
|
629
|
+
|
|
630
|
+
key = [resource.object_id, tokens]
|
|
631
|
+
seen = (counter[:boolean_targets] ||= {})
|
|
632
|
+
return if seen.key?(key)
|
|
633
|
+
|
|
634
|
+
seen[key] = true
|
|
635
|
+
counter[:count] += 1
|
|
636
|
+
problems << "schema has more than #{MAX_SUBSCHEMAS} subschemas" if counter[:count] > MAX_SUBSCHEMAS
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
# The dynamic references of a schema object. One that names no dynamic
|
|
640
|
+
# anchor is the plain reference it resolves to and is checked (and what
|
|
641
|
+
# it reaches queued) exactly as a `$ref` is; a dynamic one is not
|
|
642
|
+
# evaluated, but one pointing outside the document would need a fetch,
|
|
643
|
+
# which never happens: the schema is unusable.
|
|
644
|
+
# @return [Array<Array>] the positions the plain ones reach
|
|
645
|
+
def self.check_dynamic_refs(walk, schema, depth, dialect)
|
|
646
|
+
DYNAMIC_REFERENCE_KEYWORDS.flat_map do |keyword|
|
|
647
|
+
next [] unless schema.key?(keyword) && keyword_known?(keyword, dialect)
|
|
648
|
+
|
|
649
|
+
ref = schema[keyword]
|
|
650
|
+
# A reference that is not a URI reference at all names nothing: it
|
|
651
|
+
# is not silently ignored (a malformed keyword is not an absent one).
|
|
652
|
+
unless ref.is_a?(String)
|
|
653
|
+
walk.problems << "#{keyword} must be a string, got #{json_type(ref)}"
|
|
654
|
+
next []
|
|
655
|
+
end
|
|
656
|
+
if external_ref?(ref, walk.root, walk.counter[:dialect], walk.counter, from: schema)
|
|
657
|
+
walk.problems << "external #{keyword} #{clip(ref.inspect)} is not dereferenced"
|
|
658
|
+
next []
|
|
659
|
+
end
|
|
660
|
+
check_ref(walk, schema, depth, dialect, keyword)
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Follow a local reference (and the references it leads to) at
|
|
665
|
+
# preflight: every hop must resolve to a schema, the chain must not
|
|
666
|
+
# cycle, and it must stay within MAX_REF_DEPTH. Once the whole chain
|
|
667
|
+
# checks out, every target it reached is yielded so the caller can
|
|
668
|
+
# preflight what the reference applies.
|
|
669
|
+
# @param resolver [Hash, Context] holder of the memoized anchor index
|
|
670
|
+
# @param from [Hash] the schema object holding the reference
|
|
671
|
+
# @return [String, nil] the problem, if any
|
|
672
|
+
def self.ref_chain_problem(ref, root, dialect, resolver, from:, &block)
|
|
673
|
+
targets = []
|
|
674
|
+
problem = follow_ref_chain(ref, root, dialect, resolver, from) { |*hop| targets << hop }
|
|
675
|
+
targets.each { |target, used, origin| block.call(target, used, origin) } if problem.nil? && block
|
|
676
|
+
problem
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# @return [String, nil] the problem, if any; each resolved target is yielded
|
|
680
|
+
def self.follow_ref_chain(ref, root, dialect, resolver, from)
|
|
681
|
+
# A cycle is a chain returning to a schema it already reached: hops
|
|
682
|
+
# are told apart by where they land, not by their fragment text,
|
|
683
|
+
# since the same fragment means something else in another resource.
|
|
684
|
+
seen = {}.compare_by_identity
|
|
685
|
+
current = ref
|
|
686
|
+
loop do
|
|
687
|
+
return "$ref chain #{clip(ref.inspect)} exceeds #{MAX_REF_DEPTH} hops" if seen.size >= MAX_REF_DEPTH
|
|
688
|
+
|
|
689
|
+
target = resolve_reference(root, current, dialect, resolver, from: from)
|
|
690
|
+
return "unresolvable local $ref #{clip(current.inspect)}" if target.equal?(UNRESOLVED)
|
|
691
|
+
return "$ref #{clip(current.inspect)} does not point at a schema" unless schema_value?(target)
|
|
692
|
+
return "$ref chain #{clip(ref.inspect)} cycles" if target.is_a?(Hash) && seen.key?(target)
|
|
693
|
+
|
|
694
|
+
seen[target] = true if target.is_a?(Hash)
|
|
695
|
+
yield target, current, from
|
|
696
|
+
return nil unless target.is_a?(Hash) && target.key?('$ref')
|
|
697
|
+
|
|
698
|
+
from = target
|
|
699
|
+
current = target['$ref']
|
|
700
|
+
return "$ref must be a string, got #{json_type(current)}" unless current.is_a?(String)
|
|
701
|
+
if external_ref?(current, root, dialect, resolver, from: from)
|
|
702
|
+
return "external $ref #{clip(current.inspect)} is not dereferenced"
|
|
91
703
|
end
|
|
92
704
|
end
|
|
93
705
|
end
|
|
94
706
|
|
|
95
|
-
#
|
|
707
|
+
# @param value [Object]
|
|
708
|
+
# @return [Boolean] whether the value is a schema (object or boolean)
|
|
709
|
+
def self.schema_value?(value)
|
|
710
|
+
value.is_a?(Hash) || value == true || value == false
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# Marker for a pointer that does not resolve (nil is a valid schema
|
|
714
|
+
# value position, so it cannot serve as the marker).
|
|
715
|
+
UNRESOLVED = Object.new.freeze
|
|
716
|
+
|
|
717
|
+
# Validate data against a schema. An unusable schema (see
|
|
718
|
+
# {.check_schema}) is reported as validation errors, never as a pass,
|
|
719
|
+
# and so is a validation that hit a resource bound.
|
|
96
720
|
# Schema and data hashes may use string or symbol keys.
|
|
97
721
|
# @param data [Object] the value to validate
|
|
98
|
-
# @param schema [Hash] the JSON schema
|
|
722
|
+
# @param schema [Hash, Boolean] the JSON schema
|
|
99
723
|
# @param path [String] JSON-pointer-style location used in error messages
|
|
724
|
+
# @param deadline [Float, nil] monotonic deadline for the whole validation
|
|
100
725
|
# @return [Array<String>] human-readable validation errors (empty if valid)
|
|
101
726
|
def self.validate(data, schema, path: '#', deadline: nil)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
# One deadline covers the entire (recursive) validation.
|
|
727
|
+
# One deadline covers the entire validation, the copy of the peer's
|
|
728
|
+
# document included.
|
|
105
729
|
deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + PATTERN_MATCH_TIMEOUT
|
|
730
|
+
root = normalize_schema(schema, deadline: deadline)
|
|
731
|
+
# `preflight` comes back holding the state the check read the schema
|
|
732
|
+
# under; the check runs under the validation's deadline.
|
|
733
|
+
problems = check_normalized(root, preflight = { deadline: deadline })
|
|
734
|
+
return problems.map { |problem| "#{path}: #{problem}" } unless problems.empty?
|
|
735
|
+
|
|
736
|
+
# The index the preflight built is the one this validation reads (the
|
|
737
|
+
# resources, dialects and anchors a schema was accepted under, and the
|
|
738
|
+
# subtrees its references adopted): a second one would make what a
|
|
739
|
+
# reference resolves to depend on the order this instance applies them.
|
|
740
|
+
ctx = Context.new(root: root, deadline: deadline, dialect: canonical_dialect(dialect(root)),
|
|
741
|
+
visits: 0, errors: 0, speculative: 0, anchors: preflight[:anchors], undecided: 0,
|
|
742
|
+
depth: 0, scope: [])
|
|
743
|
+
validate_node(data, root, path, ctx, 0)
|
|
744
|
+
rescue TooLarge => e
|
|
745
|
+
["#{path}: #{e.message}"]
|
|
746
|
+
rescue Aborted => e
|
|
747
|
+
["#{path}: validation aborted: #{e.message}"]
|
|
748
|
+
rescue SystemStackError
|
|
749
|
+
# A backstop, not a working bound: the `$ref` chain and the composition
|
|
750
|
+
# branches a schema spends on one value are applied iteratively, so
|
|
751
|
+
# what one instance level costs is fixed and MAX_NODE_DEPTH bounds the
|
|
752
|
+
# stack. Should some stack still be smaller than that — the walk holds
|
|
753
|
+
# no state outside `ctx` — the unwound stack leaves nothing behind, and
|
|
754
|
+
# the validation ends the way an exhausted budget does, as one error,
|
|
755
|
+
# never as a crash out of a tool call.
|
|
756
|
+
["#{path}: validation aborted: schema too deeply recursive for this stack"]
|
|
757
|
+
end
|
|
106
758
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
759
|
+
# Validate one value against one (sub)schema, and against every schema
|
|
760
|
+
# that one leads to for the same value: a `$ref` hop, an
|
|
761
|
+
# allOf/anyOf/oneOf/not/if branch, a then/else. Applying another schema
|
|
762
|
+
# to the same value does not nest the instance, so it is the `$ref` hop
|
|
763
|
+
# budget and the visit cap that bound it; {.validate_child} accounts for
|
|
764
|
+
# the steps that do nest.
|
|
765
|
+
#
|
|
766
|
+
# Those same-instance applications run on the `pending` list here rather
|
|
767
|
+
# than on the Ruby stack (see {Evaluation}): a schema composed through N
|
|
768
|
+
# `$defs` mixins applies N of them to every instance level, and spending
|
|
769
|
+
# a frame on each would make the stack grow with the product of the
|
|
770
|
+
# instance depth and the mixin count — so data a peer can legitimately
|
|
771
|
+
# send would overflow the small stack of a transport's reader thread. A
|
|
772
|
+
# frame is spent only on the step into a child value, which is what
|
|
773
|
+
# MAX_NODE_DEPTH bounds.
|
|
774
|
+
# @param data [Object] the value
|
|
775
|
+
# @param schema [Object] a subschema (Hash or boolean)
|
|
776
|
+
# @param path [String] location for error messages
|
|
777
|
+
# @param ctx [Context] the validation context
|
|
778
|
+
# @param ref_depth [Integer] $ref hops taken to reach this schema
|
|
779
|
+
# @return [Array<String>] validation errors
|
|
780
|
+
# @raise [Aborted] when a bound is hit
|
|
781
|
+
def self.validate_node(data, schema, path, ctx, ref_depth)
|
|
782
|
+
pending = []
|
|
783
|
+
# The dynamic scope grows and shrinks with these applications, exactly
|
|
784
|
+
# as it does with the Ruby frames a recursive validator would spend:
|
|
785
|
+
# entering a schema of another resource enters that resource, and the
|
|
786
|
+
# application that entered it is the one that leaves it.
|
|
787
|
+
entered = [entered_scope?(ctx, schema)]
|
|
788
|
+
step = start_node(data, schema, path, ctx, ref_depth)
|
|
789
|
+
loop do
|
|
790
|
+
if step[0] == :apply
|
|
791
|
+
pending << step
|
|
792
|
+
# A speculative application's errors are a verdict, not output, and
|
|
793
|
+
# do not count toward MAX_ERRORS while it runs.
|
|
794
|
+
ctx.speculative += 1 if step[3]
|
|
795
|
+
entered << entered_scope?(ctx, step[1])
|
|
796
|
+
step = start_node(data, step[1], path, ctx, step[2], collecting: step[5])
|
|
797
|
+
next
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
errors = step[1]
|
|
801
|
+
leave_scope(ctx, entered.pop)
|
|
802
|
+
return errors if pending.empty?
|
|
803
|
+
|
|
804
|
+
resumed = pending.pop
|
|
805
|
+
ctx.speculative -= 1 if resumed[3]
|
|
806
|
+
# The finished application hands back its verdict and what it
|
|
807
|
+
# evaluated of the value, for the applicator that applied it.
|
|
808
|
+
step = resumed[4].call(errors, step[2])
|
|
116
809
|
end
|
|
810
|
+
ensure
|
|
811
|
+
entered&.each { |was_entered| leave_scope(ctx, was_entered) }
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
# Enter the schema resource a subschema belongs to, when applying it
|
|
815
|
+
# leaves the resource in force. A schema of the resource already
|
|
816
|
+
# innermost adds nothing a dynamic reference could read, so it is not
|
|
817
|
+
# pushed and its application has nothing to pop.
|
|
818
|
+
# @param ctx [Context] the validation context
|
|
819
|
+
# @param schema [Object] the subschema about to be applied
|
|
820
|
+
# @return [Boolean] whether the scope was pushed
|
|
821
|
+
def self.entered_scope?(ctx, schema)
|
|
822
|
+
return false unless schema.is_a?(Hash) && ctx.scope
|
|
823
|
+
|
|
824
|
+
ctx.anchors ||= anchor_index(ctx.root, ctx.dialect)
|
|
825
|
+
resource = ctx.anchors[:resources][schema] || ctx.root
|
|
826
|
+
return false if ctx.scope.last.equal?(resource)
|
|
827
|
+
|
|
828
|
+
ctx.scope.push(resource)
|
|
829
|
+
true
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# @param ctx [Context] the validation context
|
|
833
|
+
# @param entered [Boolean] what {.entered_scope?} answered
|
|
834
|
+
# @return [void]
|
|
835
|
+
def self.leave_scope(ctx, entered)
|
|
836
|
+
ctx.scope.pop if entered && ctx.scope
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
# Validate a child of the value being validated — an array item or a
|
|
840
|
+
# property value — against the subschema for it. This is the one step
|
|
841
|
+
# that nests the instance, so it is where the walk's depth is counted and
|
|
842
|
+
# where the `$ref` hop budget starts over (see {.validate_object}).
|
|
843
|
+
# @param data [Object] the child value
|
|
844
|
+
# @param schema [Object] the subschema for it (Hash or boolean)
|
|
845
|
+
# @param path [String] location for error messages
|
|
846
|
+
# @param ctx [Context] the validation context
|
|
847
|
+
# @return [Array<String>] validation errors
|
|
848
|
+
# @raise [Aborted] when a bound is hit
|
|
849
|
+
def self.validate_child(data, schema, path, ctx)
|
|
850
|
+
ctx.depth += 1
|
|
851
|
+
raise Aborted, "instance nested deeper than #{MAX_NODE_DEPTH}" if ctx.depth > MAX_NODE_DEPTH
|
|
852
|
+
|
|
853
|
+
validate_node(data, schema, path, ctx, 0)
|
|
854
|
+
ensure
|
|
855
|
+
ctx.depth -= 1
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
# The dialect in force at a schema object during validation: the one
|
|
859
|
+
# recorded for its resource by the anchor index (built once per
|
|
860
|
+
# validation), else the root's.
|
|
861
|
+
# @return [String, nil]
|
|
862
|
+
def self.node_dialect(schema, ctx)
|
|
863
|
+
ctx.anchors ||= anchor_index(ctx.root, ctx.dialect)
|
|
864
|
+
indexed_dialect(schema, ctx) || ctx.dialect
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
# Account for one node visit (boolean schemas included, so a huge array
|
|
868
|
+
# under `items: true` still runs into the bounds).
|
|
869
|
+
# @raise [Aborted]
|
|
870
|
+
def self.count_visit(ctx)
|
|
871
|
+
ctx.visits += 1
|
|
872
|
+
raise Aborted, "more than #{MAX_NODE_VISITS} schema nodes visited" if ctx.visits > MAX_NODE_VISITS
|
|
873
|
+
|
|
874
|
+
check_deadline(ctx)
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# Consult the validation-wide deadline without spending the node-visit
|
|
878
|
+
# allowance. A loop over the instance's own members (an object's
|
|
879
|
+
# properties, an array's items) is as long as the peer made it and may
|
|
880
|
+
# decide a member without visiting a node for it, so the loop itself has
|
|
881
|
+
# to reach a checkpoint: otherwise the budget is only consulted between
|
|
882
|
+
# the nodes such a sweep happens to visit, and a wide enough instance
|
|
883
|
+
# runs past it — reported, inside a speculative branch, as a pass.
|
|
884
|
+
# @raise [Aborted]
|
|
885
|
+
def self.check_deadline(ctx)
|
|
886
|
+
raise Aborted, 'validation time budget exhausted' if budget_exhausted?(ctx.deadline)
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
# Account for produced errors against MAX_ERRORS. Errors inside a
|
|
890
|
+
# speculative branch are a verdict, not output, and do not count.
|
|
891
|
+
# @return [Array<String>] the errors
|
|
892
|
+
# @raise [Aborted]
|
|
893
|
+
def self.count_errors(ctx, errors, already_counted: 0)
|
|
894
|
+
return errors if ctx.speculative.positive?
|
|
895
|
+
|
|
896
|
+
ctx.errors += errors.size - already_counted
|
|
897
|
+
raise Aborted, "more than #{MAX_ERRORS} validation errors (output truncated)" if ctx.errors > MAX_ERRORS
|
|
898
|
+
|
|
117
899
|
errors
|
|
118
900
|
end
|
|
119
901
|
|
|
902
|
+
# @return [Boolean] whether the validation-wide deadline has passed
|
|
903
|
+
def self.budget_exhausted?(deadline)
|
|
904
|
+
deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
905
|
+
end
|
|
906
|
+
|
|
120
907
|
# Validate the JSON type of a value.
|
|
121
908
|
# @param data [Object] the value
|
|
122
909
|
# @param type [String, Symbol, Array<String, Symbol>] expected type(s)
|
|
@@ -126,7 +913,7 @@ module MCPClient
|
|
|
126
913
|
types = (type.is_a?(Array) ? type : [type]).map(&:to_s)
|
|
127
914
|
return [] if types.any? { |t| type_match?(t, data) }
|
|
128
915
|
|
|
129
|
-
["#{path}: expected type #{types.join(' or ')}, got #{json_type(data)}"]
|
|
916
|
+
["#{path}: expected type #{clip(types.join(' or '))}, got #{json_type(data)}"]
|
|
130
917
|
end
|
|
131
918
|
|
|
132
919
|
# Whether a value matches a JSON Schema type name.
|
|
@@ -174,7 +961,9 @@ module MCPClient
|
|
|
174
961
|
end
|
|
175
962
|
end
|
|
176
963
|
|
|
177
|
-
# Validate enum/const membership.
|
|
964
|
+
# Validate enum/const membership. Symbol- and string-keyed objects are
|
|
965
|
+
# compared as given on both sides (the schema's data values are never
|
|
966
|
+
# re-keyed).
|
|
178
967
|
# @param data [Object] the value
|
|
179
968
|
# @param schema [Hash] string-keyed schema
|
|
180
969
|
# @param path [String] location for error messages
|
|
@@ -182,148 +971,33 @@ module MCPClient
|
|
|
182
971
|
def self.validate_enum(data, schema, path)
|
|
183
972
|
errors = []
|
|
184
973
|
if schema['enum'].is_a?(Array) && !schema['enum'].include?(data)
|
|
185
|
-
errors << "#{path}: value #{data
|
|
974
|
+
errors << "#{path}: value #{clip_value(data)} is not in enum #{clip_value(schema['enum'])}"
|
|
186
975
|
end
|
|
187
976
|
if schema.key?('const') && schema['const'] != data
|
|
188
|
-
errors << "#{path}: value #{data
|
|
189
|
-
end
|
|
190
|
-
errors
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
# Validate an object against required/properties.
|
|
194
|
-
# @param data [Hash] the object
|
|
195
|
-
# @param schema [Hash] string-keyed schema
|
|
196
|
-
# @param path [String] location for error messages
|
|
197
|
-
# @return [Array<String>] validation errors
|
|
198
|
-
def self.validate_object(data, schema, path, deadline = nil)
|
|
199
|
-
errors = []
|
|
200
|
-
Array(schema['required']).each do |raw_name|
|
|
201
|
-
name = raw_name.to_s
|
|
202
|
-
errors << "#{path}: missing required property '#{name}'" unless data.key?(name) || data.key?(name.to_sym)
|
|
203
|
-
end
|
|
204
|
-
properties = schema['properties']
|
|
205
|
-
return errors unless properties.is_a?(Hash)
|
|
206
|
-
|
|
207
|
-
properties.each do |raw_name, prop_schema|
|
|
208
|
-
next unless prop_schema.is_a?(Hash)
|
|
209
|
-
|
|
210
|
-
name = raw_name.to_s
|
|
211
|
-
key = if data.key?(name)
|
|
212
|
-
name
|
|
213
|
-
elsif data.key?(name.to_sym)
|
|
214
|
-
name.to_sym
|
|
215
|
-
end
|
|
216
|
-
next if key.nil?
|
|
217
|
-
|
|
218
|
-
errors.concat(validate(data[key], prop_schema, path: "#{path}/#{name}", deadline: deadline))
|
|
977
|
+
errors << "#{path}: value #{clip_value(data)} does not equal const #{clip_value(schema['const'])}"
|
|
219
978
|
end
|
|
220
979
|
errors
|
|
221
980
|
end
|
|
222
981
|
|
|
223
|
-
#
|
|
224
|
-
# @param
|
|
225
|
-
# @
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
errors = []
|
|
230
|
-
min_items = schema['minItems']
|
|
231
|
-
max_items = schema['maxItems']
|
|
232
|
-
if min_items.is_a?(Numeric) && data.length < min_items
|
|
233
|
-
errors << "#{path}: expected at least #{min_items} items, got #{data.length}"
|
|
234
|
-
end
|
|
235
|
-
if max_items.is_a?(Numeric) && data.length > max_items
|
|
236
|
-
errors << "#{path}: expected at most #{max_items} items, got #{data.length}"
|
|
237
|
-
end
|
|
238
|
-
items = schema['items']
|
|
239
|
-
if items.is_a?(Hash)
|
|
240
|
-
data.each_with_index do |item, idx|
|
|
241
|
-
errors.concat(validate(item, items, path: "#{path}/#{idx}", deadline: deadline))
|
|
242
|
-
end
|
|
243
|
-
end
|
|
244
|
-
errors
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
# Validate a string against minLength/maxLength/pattern.
|
|
248
|
-
# @param data [String] the string
|
|
249
|
-
# @param schema [Hash] string-keyed schema
|
|
250
|
-
# @param path [String] location for error messages
|
|
251
|
-
# @return [Array<String>] validation errors
|
|
252
|
-
def self.validate_string(data, schema, path, deadline = nil)
|
|
253
|
-
errors = []
|
|
254
|
-
min_length = schema['minLength']
|
|
255
|
-
max_length = schema['maxLength']
|
|
256
|
-
if min_length.is_a?(Numeric) && data.length < min_length
|
|
257
|
-
errors << "#{path}: string is shorter than minLength #{min_length}"
|
|
258
|
-
end
|
|
259
|
-
if max_length.is_a?(Numeric) && data.length > max_length
|
|
260
|
-
errors << "#{path}: string is longer than maxLength #{max_length}"
|
|
261
|
-
end
|
|
262
|
-
errors.concat(validate_pattern(data, schema['pattern'], path, deadline))
|
|
263
|
-
errors
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
# Validate a string against a regular-expression pattern.
|
|
267
|
-
# Invalid patterns are not enforced.
|
|
268
|
-
#
|
|
269
|
-
# The pattern comes from the tool's outputSchema, i.e. from the remote
|
|
270
|
-
# server, so matching runs against the validation-wide deadline: neither a
|
|
271
|
-
# single expensive expression nor many cheap-looking ones can pin the
|
|
272
|
-
# calling thread. A match that exceeds the budget is reported as a
|
|
273
|
-
# validation error rather than silently accepted — the value was never
|
|
274
|
-
# shown to satisfy the schema.
|
|
275
|
-
# @param data [String] the string
|
|
276
|
-
# @param pattern [Object] the pattern keyword value
|
|
277
|
-
# @param path [String] location for error messages
|
|
278
|
-
# @param deadline [Float, nil] monotonic deadline for the whole validation
|
|
279
|
-
# @return [Array<String>] validation errors
|
|
280
|
-
def self.validate_pattern(data, pattern, path, deadline = nil)
|
|
281
|
-
return [] unless pattern.is_a?(String)
|
|
282
|
-
|
|
283
|
-
remaining = pattern_budget_remaining(deadline)
|
|
284
|
-
return ["#{path}: pattern matching budget exhausted before #{pattern.inspect}"] if remaining.zero?
|
|
285
|
-
|
|
286
|
-
return [] if data.match?(Regexp.new(pattern, timeout: remaining))
|
|
287
|
-
|
|
288
|
-
["#{path}: string does not match pattern #{pattern.inspect}"]
|
|
289
|
-
rescue Regexp::TimeoutError
|
|
290
|
-
["#{path}: pattern #{pattern.inspect} exceeded the #{PATTERN_MATCH_TIMEOUT}s matching budget"]
|
|
291
|
-
rescue RegexpError
|
|
292
|
-
[]
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
# Time left in the validation-wide pattern budget.
|
|
296
|
-
# @param deadline [Float, nil] monotonic deadline, or nil for a lone match
|
|
297
|
-
# @return [Float] seconds available for the next match; 0.0 when exhausted
|
|
298
|
-
def self.pattern_budget_remaining(deadline)
|
|
299
|
-
return PATTERN_MATCH_TIMEOUT unless deadline
|
|
300
|
-
|
|
301
|
-
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
302
|
-
return 0.0 if remaining <= 0
|
|
303
|
-
|
|
304
|
-
[remaining, MIN_PATTERN_MATCH_TIMEOUT].max
|
|
982
|
+
# Bound a piece of peer-derived text destined for a message.
|
|
983
|
+
# @param text [String]
|
|
984
|
+
# @return [String]
|
|
985
|
+
def self.clip(text)
|
|
986
|
+
text = text.to_s
|
|
987
|
+
text.length > MAX_VALUE_INSPECT ? "#{text[0, MAX_VALUE_INSPECT]}..." : text
|
|
305
988
|
end
|
|
306
989
|
|
|
307
|
-
#
|
|
308
|
-
#
|
|
309
|
-
# @param
|
|
310
|
-
# @
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
exclusive_max = schema['exclusiveMaximum']
|
|
318
|
-
errors << "#{path}: value #{data} is less than minimum #{minimum}" if minimum.is_a?(Numeric) && data < minimum
|
|
319
|
-
errors << "#{path}: value #{data} is greater than maximum #{maximum}" if maximum.is_a?(Numeric) && data > maximum
|
|
320
|
-
if exclusive_min.is_a?(Numeric) && data <= exclusive_min
|
|
321
|
-
errors << "#{path}: value #{data} must be greater than exclusiveMinimum #{exclusive_min}"
|
|
322
|
-
end
|
|
323
|
-
if exclusive_max.is_a?(Numeric) && data >= exclusive_max
|
|
324
|
-
errors << "#{path}: value #{data} must be less than exclusiveMaximum #{exclusive_max}"
|
|
990
|
+
# A short rendering of a value for a message that never inspects a
|
|
991
|
+
# large value whole.
|
|
992
|
+
# @param value [Object]
|
|
993
|
+
# @return [String]
|
|
994
|
+
def self.clip_value(value)
|
|
995
|
+
case value
|
|
996
|
+
when String then clip(value[0, MAX_VALUE_INSPECT].inspect)
|
|
997
|
+
when Array then "array(#{value.length} items)"
|
|
998
|
+
when Hash then "object(#{value.length} keys)"
|
|
999
|
+
else clip(value.inspect)
|
|
325
1000
|
end
|
|
326
|
-
errors
|
|
327
1001
|
end
|
|
328
1002
|
end
|
|
329
1003
|
end
|