versponse 0.1.0 → 0.2.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/.rubocop.yml +15 -6
- data/CHANGELOG.md +59 -1
- data/CODE_OF_CONDUCT.md +1 -1
- data/LICENSE.txt +1 -1
- data/README.md +674 -20
- data/Rakefile +26 -3
- data/lib/versponse/base/class_methods.rb +180 -0
- data/lib/versponse/base/dsl.rb +231 -0
- data/lib/versponse/base.rb +35 -0
- data/lib/versponse/builder.rb +423 -0
- data/lib/versponse/cache.rb +47 -0
- data/lib/versponse/callable.rb +74 -0
- data/lib/versponse/class_policies.rb +15 -0
- data/lib/versponse/collapse_if_plan.rb +26 -0
- data/lib/versponse/collections.rb +49 -0
- data/lib/versponse/components.rb +37 -0
- data/lib/versponse/condition.rb +33 -0
- data/lib/versponse/configuration.rb +261 -0
- data/lib/versponse/const_as_enum.rb +39 -0
- data/lib/versponse/context_override/call.rb +27 -0
- data/lib/versponse/context_override/merge.rb +23 -0
- data/lib/versponse/context_override.rb +25 -0
- data/lib/versponse/context_plan.rb +24 -0
- data/lib/versponse/deep_copy.rb +27 -0
- data/lib/versponse/deep_freeze.rb +20 -0
- data/lib/versponse/discriminated_plan.rb +26 -0
- data/lib/versponse/error_policy/recovery.rb +68 -0
- data/lib/versponse/error_policy.rb +97 -0
- data/lib/versponse/expansion.rb +46 -0
- data/lib/versponse/extractors/block.rb +24 -0
- data/lib/versponse/extractors/fallback.rb +27 -0
- data/lib/versponse/extractors/itself.rb +21 -0
- data/lib/versponse/extractors/key.rb +28 -0
- data/lib/versponse/extractors/path.rb +28 -0
- data/lib/versponse/extractors.rb +23 -0
- data/lib/versponse/field_order.rb +23 -0
- data/lib/versponse/field_type.rb +88 -0
- data/lib/versponse/formatters.rb +38 -0
- data/lib/versponse/gem_version.rb +6 -0
- data/lib/versponse/inline_validation/builder.rb +129 -0
- data/lib/versponse/inline_validation/state.rb +16 -0
- data/lib/versponse/inline_validation.rb +86 -0
- data/lib/versponse/internals.rb +553 -0
- data/lib/versponse/json_value.rb +22 -0
- data/lib/versponse/lazy_plan.rb +30 -0
- data/lib/versponse/naming.rb +68 -0
- data/lib/versponse/object_schema.rb +25 -0
- data/lib/versponse/open_api/builder.rb +159 -0
- data/lib/versponse/open_api/component_set.rb +33 -0
- data/lib/versponse/open_api/declarations.rb +90 -0
- data/lib/versponse/open_api/document.rb +187 -0
- data/lib/versponse/open_api/ref_check.rb +89 -0
- data/lib/versponse/open_api/rswag_specs.rb +98 -0
- data/lib/versponse/open_api/sources.rb +72 -0
- data/lib/versponse/open_api/values.rb +29 -0
- data/lib/versponse/open_api.rb +12 -0
- data/lib/versponse/output_validator.rb +82 -0
- data/lib/versponse/pattern.rb +38 -0
- data/lib/versponse/plan.rb +104 -0
- data/lib/versponse/properties/array.rb +56 -0
- data/lib/versponse/properties/base.rb +325 -0
- data/lib/versponse/properties/collection.rb +135 -0
- data/lib/versponse/properties/const.rb +46 -0
- data/lib/versponse/properties/container.rb +51 -0
- data/lib/versponse/properties/enum.rb +93 -0
- data/lib/versponse/properties/field.rb +43 -0
- data/lib/versponse/properties/item_one_of.rb +19 -0
- data/lib/versponse/properties/object.rb +32 -0
- data/lib/versponse/properties/one_of.rb +176 -0
- data/lib/versponse/properties/reference.rb +133 -0
- data/lib/versponse/references.rb +162 -0
- data/lib/versponse/registry.rb +124 -0
- data/lib/versponse/schema_context.rb +69 -0
- data/lib/versponse/schema_merge.rb +24 -0
- data/lib/versponse/selection/scope.rb +36 -0
- data/lib/versponse/selection.rb +92 -0
- data/lib/versponse/selection_schema.rb +101 -0
- data/lib/versponse/steps/checked.rb +43 -0
- data/lib/versponse/steps/checked_read.rb +46 -0
- data/lib/versponse/steps/checked_read_formatted.rb +32 -0
- data/lib/versponse/steps/checked_value.rb +48 -0
- data/lib/versponse/steps/const.rb +44 -0
- data/lib/versponse/steps/nested.rb +98 -0
- data/lib/versponse/steps/one_of.rb +62 -0
- data/lib/versponse/steps/read.rb +48 -0
- data/lib/versponse/steps/read_formatted.rb +28 -0
- data/lib/versponse/steps/value.rb +52 -0
- data/lib/versponse/supported_versions.rb +61 -0
- data/lib/versponse/validator/all.rb +32 -0
- data/lib/versponse/validator/array_check.rb +48 -0
- data/lib/versponse/validator/boolean_check.rb +16 -0
- data/lib/versponse/validator/both.rb +24 -0
- data/lib/versponse/validator/class_check.rb +25 -0
- data/lib/versponse/validator/closed_object_check.rb +78 -0
- data/lib/versponse/validator/combinator.rb +40 -0
- data/lib/versponse/validator/compiler.rb +146 -0
- data/lib/versponse/validator/constant.rb +33 -0
- data/lib/versponse/validator/date_time_check.rb +21 -0
- data/lib/versponse/validator/format_check.rb +49 -0
- data/lib/versponse/validator/integer_check.rb +16 -0
- data/lib/versponse/validator/nullable_check.rb +22 -0
- data/lib/versponse/validator/number_check.rb +58 -0
- data/lib/versponse/validator/object_check.rb +90 -0
- data/lib/versponse/validator/ref.rb +38 -0
- data/lib/versponse/validator/root.rb +31 -0
- data/lib/versponse/validator/string_check.rb +48 -0
- data/lib/versponse/validator/string_keys.rb +21 -0
- data/lib/versponse/validator/type_check.rb +84 -0
- data/lib/versponse/validator/value_check.rb +36 -0
- data/lib/versponse/validator.rb +10 -0
- data/lib/versponse/variant_dispatcher.rb +35 -0
- data/lib/versponse/version.rb +179 -1
- data/lib/versponse/version_resolver.rb +27 -0
- data/lib/versponse/versions.rb +116 -0
- data/lib/versponse/view.rb +126 -0
- data/lib/versponse/wrapper.rb +53 -0
- data/lib/versponse.rb +165 -3
- metadata +151 -12
- data/sig/versponse.rbs +0 -4
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Validator
|
|
5
|
+
# Compiles a validation schema into a checker that responds to valid?(data)
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class Compiler
|
|
9
|
+
# Raised inside the compiler for a keyword or value it doesn't support
|
|
10
|
+
Unsupported = Class.new(StandardError)
|
|
11
|
+
|
|
12
|
+
# Keywords that don't affect validity (x- extensions too). discriminator is not one: json_schemer uses it
|
|
13
|
+
# to pick the oneOf variant it checks.
|
|
14
|
+
ANNOTATIONS = %w[
|
|
15
|
+
description title example examples default deprecated readOnly writeOnly $comment
|
|
16
|
+
nullable xml externalDocs
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
# JSON Schema versions that ignore keywords next to $ref
|
|
20
|
+
REF_ONLY = %i[draft4 draft6 draft7 openapi30].freeze
|
|
21
|
+
|
|
22
|
+
# Checker classes, each building itself from the keywords it owns
|
|
23
|
+
CHECKS = [TypeCheck, ValueCheck, ObjectCheck, ArrayCheck, StringCheck, FormatCheck, NumberCheck, Combinator, Ref].freeze
|
|
24
|
+
|
|
25
|
+
# Keywords naming a schema for "#Name" references, by JSON Schema version (see Configuration#anchor)
|
|
26
|
+
ANCHORS = %w[$anchor $id id].freeze
|
|
27
|
+
|
|
28
|
+
# Keywords holding schemas that are only referenced (SelectionSchema keeps anchored originals there)
|
|
29
|
+
DEFINITIONS = %w[$defs definitions].freeze
|
|
30
|
+
|
|
31
|
+
# Every keyword the compiler accepts
|
|
32
|
+
KEYWORDS = (ANNOTATIONS + CHECKS.flat_map(&:keywords) + ANCHORS + DEFINITIONS).freeze
|
|
33
|
+
|
|
34
|
+
# @param schema [Hash] validation schema
|
|
35
|
+
# @param schemer [JSONSchemer::Schema] json_schemer schema of the same Hash, used for patterns and formats
|
|
36
|
+
# @return [#valid?, nil] the checker, or nil when the schema uses an unsupported keyword
|
|
37
|
+
def self.call(schema, schemer)
|
|
38
|
+
new(schemer).root(schema)
|
|
39
|
+
rescue Unsupported
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param schemer [JSONSchemer::Schema] json_schemer schema, used for patterns and formats
|
|
44
|
+
def initialize(schemer)
|
|
45
|
+
@schemer = schemer
|
|
46
|
+
@anchors = {}
|
|
47
|
+
@refs = []
|
|
48
|
+
@compiled = {}.compare_by_identity
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param schema [Hash] root validation schema
|
|
52
|
+
# @return [#valid?] the checker
|
|
53
|
+
# @raise [Unsupported] for an unsupported keyword or an unknown anchor
|
|
54
|
+
def root(schema)
|
|
55
|
+
checker = compile(schema)
|
|
56
|
+
raise Unsupported, "unknown anchor" unless (@refs - @anchors.keys).empty?
|
|
57
|
+
|
|
58
|
+
@anchors.freeze
|
|
59
|
+
Root.new(checker)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param schema [Hash, Boolean] schema or subschema
|
|
63
|
+
# @return [#valid?] the checker
|
|
64
|
+
# @raise [Unsupported] for an unsupported keyword
|
|
65
|
+
def compile(schema)
|
|
66
|
+
return Constant.for(schema) if schema == true || schema == false
|
|
67
|
+
raise Unsupported, schema.inspect unless schema.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
@compiled[schema] = compile_hash(schema)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param schema [Hash, Boolean] a subschema of the root compiled with {#root}, the same object
|
|
73
|
+
# @return [#valid?] its checker
|
|
74
|
+
# @raise [Unsupported] for a Hash this compiler hasn't compiled
|
|
75
|
+
def checker_for(schema)
|
|
76
|
+
return Constant.for(schema) if schema == true || schema == false
|
|
77
|
+
|
|
78
|
+
@compiled.fetch(schema) { raise Unsupported, "not compiled" }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @param schema [Hash] schema or subschema
|
|
82
|
+
# @return [#valid?] the checker
|
|
83
|
+
def compile_hash(schema)
|
|
84
|
+
schema = schema.slice("$ref", *ANCHORS) if schema.key?("$ref") && REF_ONLY.include?(Versponse.configuration.json_schema_version)
|
|
85
|
+
DEFINITIONS.each { |keyword| schema[keyword].each_value { |definition| compile(definition) } if schema[keyword].is_a?(Hash) }
|
|
86
|
+
raise Unsupported, schema.inspect unless schema.keys.all? { |key| KEYWORDS.include?(key) || key.start_with?("x-") }
|
|
87
|
+
|
|
88
|
+
name = anchor_name(schema)
|
|
89
|
+
checker = All.for(fold(CHECKS.filter_map { |check| check.build(schema, self) }))
|
|
90
|
+
@anchors[name] = checker if name
|
|
91
|
+
checker
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# type: "object", "array" or "string" folds into the one check built for that shape, made strict, so a
|
|
95
|
+
# value takes one check instead of two. With no such check (an unknown format builds none) or two (string
|
|
96
|
+
# keywords and a format), the type check stays.
|
|
97
|
+
#
|
|
98
|
+
# @param checks [Array<#valid?>] checks built from one schema; TypeCheck comes first in CHECKS
|
|
99
|
+
# @return [Array<#valid?>] the checks, with the type folded in when it can be
|
|
100
|
+
def fold(checks)
|
|
101
|
+
type = checks.first
|
|
102
|
+
return checks unless type.is_a?(ClassCheck)
|
|
103
|
+
|
|
104
|
+
owners = checks.select { |check| check.class.respond_to?(:shape) && check.class.shape == type.klass }
|
|
105
|
+
return checks unless owners.one?
|
|
106
|
+
|
|
107
|
+
checks.drop(1).map { |check| check.equal?(owners.first) ? check.strict : check }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# $id and id name an anchor only as a plain "#Name" fragment; other ids change the base URI
|
|
111
|
+
#
|
|
112
|
+
# @param schema [Hash] schema
|
|
113
|
+
# @return [String, nil] the anchor name the schema declares
|
|
114
|
+
# @raise [Unsupported] for an $id or id that isn't a plain-name fragment
|
|
115
|
+
def anchor_name(schema)
|
|
116
|
+
return schema["$anchor"] if schema.key?("$anchor")
|
|
117
|
+
|
|
118
|
+
id = schema["$id"] || schema["id"]
|
|
119
|
+
return if id.nil?
|
|
120
|
+
raise Unsupported, "id #{id}" unless id.is_a?(String) && id.start_with?("#")
|
|
121
|
+
|
|
122
|
+
id.delete_prefix("#")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @param name [String] anchor name, without "#"
|
|
126
|
+
# @return [Versponse::Validator::Ref] a reference resolved when validating
|
|
127
|
+
def ref(name)
|
|
128
|
+
@refs << name
|
|
129
|
+
Ref.new(@anchors, name)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @param pattern [String] pattern keyword
|
|
133
|
+
# @return [Regexp] the Regexp json_schemer uses for it
|
|
134
|
+
def regexp(pattern)
|
|
135
|
+
@schemer.resolve_regexp(pattern)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @param name [String] format keyword
|
|
139
|
+
# @return [Proc, nil] json_schemer's format check, called with (data, name); nil for a format
|
|
140
|
+
# json_schemer doesn't know, which it treats as an annotation
|
|
141
|
+
def format(name)
|
|
142
|
+
@schemer.fetch_format(name, nil)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Validator
|
|
5
|
+
# The checker of a boolean schema, or of a schema without assertions
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class Constant
|
|
9
|
+
# @param result [Boolean] the result of every check
|
|
10
|
+
def initialize(result)
|
|
11
|
+
@result = result
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param _data [Object] ignored
|
|
16
|
+
# @return [Boolean] the constant result
|
|
17
|
+
def valid?(_data)
|
|
18
|
+
@result
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Accepts anything
|
|
22
|
+
VALID = new(true)
|
|
23
|
+
# Rejects anything
|
|
24
|
+
INVALID = new(false)
|
|
25
|
+
|
|
26
|
+
# @param result [Boolean] schema true or false
|
|
27
|
+
# @return [Constant] VALID or INVALID
|
|
28
|
+
def self.for(result)
|
|
29
|
+
result ? VALID : INVALID
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Validator
|
|
5
|
+
# The date-time format, with a fast path for the timestamps Versponse renders. A String matching
|
|
6
|
+
# {FAST} is valid without json_schemer; everything else is left to json_schemer's check.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class DateTimeCheck < FormatCheck
|
|
10
|
+
# RFC 3339 date-times that json_schemer certainly accepts: days 01-28 (no calendar check needed), no leap
|
|
11
|
+
# seconds, uppercase T and Z, bounded offsets, anchored. Fuzzed against json_schemer 2.5.0 with no false accept.
|
|
12
|
+
FAST = /\A\d{4}-(0[1-9]|1[0-2])-(0[1-9]|1\d|2[0-8])T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d+)?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)\z/
|
|
13
|
+
|
|
14
|
+
# @param data [Object] value to check; anything but a String passes, as in json_schemer, unless strict
|
|
15
|
+
# @return [Boolean] true when data is a valid date-time
|
|
16
|
+
def valid?(data)
|
|
17
|
+
data.is_a?(String) ? (FAST.match?(data) || @check.call(data, @name)) : !@strict
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Validator
|
|
5
|
+
# format, checked with json_schemer's own format procs
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class FormatCheck
|
|
9
|
+
# @return [Array<String>] keywords this check owns
|
|
10
|
+
def self.keywords = %w[format]
|
|
11
|
+
|
|
12
|
+
# @return [Class] the class a type folded into this check requires
|
|
13
|
+
def self.shape = String
|
|
14
|
+
|
|
15
|
+
# @param schema [Hash] schema
|
|
16
|
+
# @param compiler [Versponse::Validator::Compiler] looks formats up like json_schemer
|
|
17
|
+
# @return [FormatCheck, nil] the check, nil without format or for an unknown one (which accepts anything)
|
|
18
|
+
def self.build(schema, compiler)
|
|
19
|
+
return unless schema.key?("format")
|
|
20
|
+
|
|
21
|
+
check = compiler.format(schema["format"])
|
|
22
|
+
return unless check
|
|
23
|
+
|
|
24
|
+
(schema["format"] == "date-time") ? DateTimeCheck.new(schema["format"], check) : new(schema["format"], check)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param name [String] format name
|
|
28
|
+
# @param check [Proc] json_schemer's check, called with (data, name)
|
|
29
|
+
# @param strict [Boolean] true when anything but a String fails (type: "string" folded in)
|
|
30
|
+
def initialize(name, check, strict: false)
|
|
31
|
+
@name = name
|
|
32
|
+
@check = check
|
|
33
|
+
@strict = strict
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [FormatCheck] the same check, failing anything but a String
|
|
38
|
+
def strict
|
|
39
|
+
self.class.new(@name, @check, strict: true)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param data [Object] value to check (each format proc skips types it doesn't apply to; strict fails them)
|
|
43
|
+
# @return [Boolean] true when data matches the format
|
|
44
|
+
def valid?(data)
|
|
45
|
+
(@strict && !data.is_a?(String)) ? false : @check.call(data, @name)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Validator
|
|
5
|
+
# type: integer from JSON Schema 6 on: json_schemer's own test, so 1.0 passes and NaN raises (Root then defers)
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class IntegerCheck
|
|
9
|
+
# @param data [Object] value to check
|
|
10
|
+
# @return [Boolean] true when data is an integer
|
|
11
|
+
def valid?(data)
|
|
12
|
+
JSONSchemer::Draft202012::Vocab::Validation::Type.valid_integer?(data)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Validator
|
|
5
|
+
# type: [X, "null"]: nil, or what the single-type check of X accepts
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class NullableCheck
|
|
9
|
+
# @param inner [#valid?] check of the other type
|
|
10
|
+
def initialize(inner)
|
|
11
|
+
@inner = inner
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param data [Object] value to check
|
|
16
|
+
# @return [Boolean] true when data is nil or passes the inner check
|
|
17
|
+
def valid?(data)
|
|
18
|
+
data.nil? || @inner.valid?(data)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Versponse
|
|
6
|
+
module Validator
|
|
7
|
+
# minimum, maximum, exclusiveMinimum, exclusiveMaximum and multipleOf, as json_schemer compares them
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class NumberCheck
|
|
11
|
+
# Keywords and the comparison data must pass against their value
|
|
12
|
+
BOUNDS = {"minimum" => :>=, "maximum" => :<=, "exclusiveMinimum" => :>, "exclusiveMaximum" => :<}.freeze
|
|
13
|
+
|
|
14
|
+
# @return [Array<String>] keywords this check owns
|
|
15
|
+
def self.keywords = BOUNDS.keys + %w[multipleOf]
|
|
16
|
+
|
|
17
|
+
# @param schema [Hash] schema
|
|
18
|
+
# @param _compiler [Versponse::Validator::Compiler] unused
|
|
19
|
+
# @return [NumberCheck, nil] the check, nil without number keywords
|
|
20
|
+
def self.build(schema, _compiler)
|
|
21
|
+
return unless keywords.any? { |keyword| schema.key?(keyword) }
|
|
22
|
+
|
|
23
|
+
new(bounds(schema), schema["multipleOf"])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A boolean exclusiveMinimum/exclusiveMaximum (draft 4, OpenAPI 3.0) makes minimum/maximum exclusive
|
|
27
|
+
def self.bounds(schema)
|
|
28
|
+
bounds = BOUNDS.filter_map { |keyword, operator| [operator, schema[keyword]] if schema[keyword].is_a?(Numeric) }
|
|
29
|
+
bounds << [:>, schema["minimum"]] if schema["exclusiveMinimum"] == true && schema["minimum"].is_a?(Numeric)
|
|
30
|
+
bounds << [:<, schema["maximum"]] if schema["exclusiveMaximum"] == true && schema["maximum"].is_a?(Numeric)
|
|
31
|
+
bounds
|
|
32
|
+
end
|
|
33
|
+
private_class_method :bounds
|
|
34
|
+
|
|
35
|
+
# @param bounds [Array<Array(Symbol, Numeric)>] comparisons data must pass
|
|
36
|
+
# @param multiple_of [Numeric, nil] divisor, nil without multipleOf
|
|
37
|
+
def initialize(bounds, multiple_of)
|
|
38
|
+
@bounds = bounds.freeze
|
|
39
|
+
@multiple_of = multiple_of
|
|
40
|
+
freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param data [Object] value to check; anything but a Numeric passes
|
|
44
|
+
# @return [Boolean] true when the number matches
|
|
45
|
+
def valid?(data)
|
|
46
|
+
return true unless data.is_a?(Numeric)
|
|
47
|
+
|
|
48
|
+
@bounds.all? { |operator, value| data.public_send(operator, value) } && multiple?(data)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def multiple?(data)
|
|
54
|
+
@multiple_of.nil? || BigDecimal(data.to_s).modulo(@multiple_of).zero?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Validator
|
|
5
|
+
# properties, required, additionalProperties, minProperties and maxProperties
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class ObjectCheck
|
|
9
|
+
# @return [Array<String>] keywords this check owns
|
|
10
|
+
def self.keywords = %w[properties required additionalProperties minProperties maxProperties]
|
|
11
|
+
|
|
12
|
+
# @return [Class] the class a type folded into this check requires
|
|
13
|
+
def self.shape = Hash
|
|
14
|
+
|
|
15
|
+
# @param schema [Hash] schema
|
|
16
|
+
# @param compiler [Versponse::Validator::Compiler] compiles subschemas
|
|
17
|
+
# @return [ObjectCheck, ClosedObjectCheck, nil] the check, nil without object keywords
|
|
18
|
+
def self.build(schema, compiler)
|
|
19
|
+
return unless keywords.any? { |keyword| schema.key?(keyword) }
|
|
20
|
+
|
|
21
|
+
properties = schema.fetch("properties", {}).transform_values { |property| compiler.compile(property) }
|
|
22
|
+
required = schema.fetch("required", [])
|
|
23
|
+
sizes = Range.new(schema["minProperties"], schema["maxProperties"])
|
|
24
|
+
if schema["additionalProperties"] == false && required.all? { |key| properties.key?(key) }
|
|
25
|
+
return ClosedObjectCheck.new(properties:, required:, sizes:)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
new(properties:, required:, additional: compiler.compile(schema.fetch("additionalProperties", true)), sizes:)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param properties [Hash{String => #valid?}] checkers of named properties
|
|
32
|
+
# @param required [Array<String>] required keys
|
|
33
|
+
# @param additional [#valid?] checker of other properties
|
|
34
|
+
# @param sizes [Range] allowed number of properties
|
|
35
|
+
# @param strict [Boolean] true when anything but a Hash fails (type: "object" folded in)
|
|
36
|
+
def initialize(properties:, required:, additional:, sizes:, strict: false)
|
|
37
|
+
@properties = properties.freeze
|
|
38
|
+
@required = required.freeze
|
|
39
|
+
@additional = additional
|
|
40
|
+
@sizes = sizes
|
|
41
|
+
@strict = strict
|
|
42
|
+
freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [ObjectCheck] the same check, failing anything but a Hash
|
|
46
|
+
def strict
|
|
47
|
+
self.class.new(properties: @properties, required: @required, additional: @additional, sizes: @sizes, strict: true)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A Hash with Symbol keys is checked with their Strings; any other non-String key throws {Root::DEFER}, which
|
|
51
|
+
# hands it to json_schemer
|
|
52
|
+
#
|
|
53
|
+
# @param data [Object] value to check; anything but a Hash passes unless strict
|
|
54
|
+
# @return [Boolean] true when the Hash matches
|
|
55
|
+
def valid?(data)
|
|
56
|
+
return !@strict unless data.is_a?(Hash)
|
|
57
|
+
return reject(data) unless @sizes.cover?(data.size)
|
|
58
|
+
|
|
59
|
+
@required.each { |key| return reject(data) unless data.key?(key) }
|
|
60
|
+
data.each do |key, value|
|
|
61
|
+
return with_string_keys(data) unless key.is_a?(String)
|
|
62
|
+
return false unless (@properties[key] || @additional).valid?(value)
|
|
63
|
+
end
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# json_schemer reads Symbol keys as Strings, so a false is certain only for a Hash with String keys; one with
|
|
70
|
+
# Symbol keys is checked again with their Strings
|
|
71
|
+
#
|
|
72
|
+
# @param data [Hash] value being checked
|
|
73
|
+
# @return [Boolean] false, or the answer for the Hash with String keys
|
|
74
|
+
def reject(data)
|
|
75
|
+
data.each_key { |key| return with_string_keys(data) unless key.is_a?(String) }
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param data [Hash] value with a non-String key
|
|
80
|
+
# @return [Boolean] the answer for the Hash with String keys; throws {Root::DEFER} when the keys can't all be
|
|
81
|
+
# read as Strings (inside oneOf or not, a false would be read as a real answer)
|
|
82
|
+
def with_string_keys(data)
|
|
83
|
+
strings = StringKeys.call(data)
|
|
84
|
+
throw Root::DEFER unless strings
|
|
85
|
+
|
|
86
|
+
valid?(strings)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Validator
|
|
5
|
+
# $ref to a local $anchor ("#Name"), resolved when validating so recursive schemas work
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class Ref
|
|
9
|
+
# @return [Array<String>] keywords this check owns
|
|
10
|
+
def self.keywords = %w[$ref]
|
|
11
|
+
|
|
12
|
+
# @param schema [Hash] schema
|
|
13
|
+
# @param compiler [Versponse::Validator::Compiler] registers the reference
|
|
14
|
+
# @return [Ref, nil] the check, nil without $ref
|
|
15
|
+
# @raise [Versponse::Validator::Compiler::Unsupported] for anything but a plain-name fragment
|
|
16
|
+
def self.build(schema, compiler)
|
|
17
|
+
return unless schema.key?("$ref")
|
|
18
|
+
raise Compiler::Unsupported, schema["$ref"] unless schema["$ref"].match?(/\A#[A-Za-z_][-A-Za-z0-9._]*\z/)
|
|
19
|
+
|
|
20
|
+
compiler.ref(schema["$ref"].delete_prefix("#"))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param anchors [Hash{String => #valid?}] anchors of the root schema, filled while compiling
|
|
24
|
+
# @param name [String] anchor name
|
|
25
|
+
def initialize(anchors, name)
|
|
26
|
+
@anchors = anchors
|
|
27
|
+
@name = name
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param data [Object] value to check
|
|
32
|
+
# @return [Boolean] true when data matches the anchored schema
|
|
33
|
+
def valid?(data)
|
|
34
|
+
@anchors.fetch(@name).valid?(data)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Validator
|
|
5
|
+
# Compiled checker of a whole schema. A check that can't answer (a non-String key) throws {DEFER},
|
|
6
|
+
# which this root turns into false, so the caller asks json_schemer, instead of a false that a
|
|
7
|
+
# oneOf or not further up would read as a real answer.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class Root
|
|
11
|
+
# Thrown by a check that leaves the value to json_schemer
|
|
12
|
+
DEFER = :versponse_validator_defer
|
|
13
|
+
|
|
14
|
+
# @param checker [#valid?] compiled checker
|
|
15
|
+
def initialize(checker)
|
|
16
|
+
@checker = checker
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param data [Object] value to check
|
|
21
|
+
# @return [Boolean] true when the value matches; false when it doesn't or json_schemer has to decide
|
|
22
|
+
# (a check deferred, or raised, e.g. comparing Infinity)
|
|
23
|
+
def valid?(data)
|
|
24
|
+
catch(DEFER) { return @checker.valid?(data) }
|
|
25
|
+
false
|
|
26
|
+
rescue
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Validator
|
|
5
|
+
# minLength, maxLength and pattern
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class StringCheck
|
|
9
|
+
# @return [Array<String>] keywords this check owns
|
|
10
|
+
def self.keywords = %w[minLength maxLength pattern]
|
|
11
|
+
|
|
12
|
+
# @return [Class] the class a type folded into this check requires
|
|
13
|
+
def self.shape = String
|
|
14
|
+
|
|
15
|
+
# @param schema [Hash] schema
|
|
16
|
+
# @param compiler [Versponse::Validator::Compiler] resolves patterns like json_schemer
|
|
17
|
+
# @return [StringCheck, nil] the check, nil without string keywords
|
|
18
|
+
def self.build(schema, compiler)
|
|
19
|
+
return unless keywords.any? { |keyword| schema.key?(keyword) }
|
|
20
|
+
|
|
21
|
+
new(Range.new(schema["minLength"], schema["maxLength"]), schema["pattern"]&.then { |pattern| compiler.regexp(pattern) })
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param lengths [Range] allowed lengths in characters
|
|
25
|
+
# @param pattern [Regexp, nil] pattern matched anywhere in the string
|
|
26
|
+
# @param strict [Boolean] true when anything but a String fails (type: "string" folded in)
|
|
27
|
+
def initialize(lengths, pattern, strict: false)
|
|
28
|
+
@lengths = lengths
|
|
29
|
+
@pattern = pattern
|
|
30
|
+
@strict = strict
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [StringCheck] the same check, failing anything but a String
|
|
35
|
+
def strict
|
|
36
|
+
self.class.new(@lengths, @pattern, strict: true)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param data [Object] value to check; anything but a String passes unless strict
|
|
40
|
+
# @return [Boolean] true when the String matches
|
|
41
|
+
def valid?(data)
|
|
42
|
+
return !@strict unless data.is_a?(String)
|
|
43
|
+
|
|
44
|
+
@lengths.cover?(data.size) && (@pattern.nil? || @pattern.match?(data))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Validator
|
|
5
|
+
# json_schemer reads Symbol keys as their Strings, so the object checks do too: a Hash with Symbol keys is
|
|
6
|
+
# checked again with String keys
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
module StringKeys
|
|
10
|
+
# @param data [Hash] value being checked
|
|
11
|
+
# @return [Hash{String => Object}, nil] the Hash with String keys, nil when a key is neither a String nor a
|
|
12
|
+
# Symbol, or a Symbol and a String name the same key (json_schemer decides those)
|
|
13
|
+
def self.call(data)
|
|
14
|
+
return unless data.each_key.all? { |key| key.is_a?(String) || key.is_a?(Symbol) }
|
|
15
|
+
|
|
16
|
+
strings = data.transform_keys(&:to_s)
|
|
17
|
+
strings if strings.size == data.size
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Validator
|
|
5
|
+
# type, with json_schemer's semantics (integer accepts 1.0, number includes NaN)
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class TypeCheck
|
|
9
|
+
# Type tests, matching json_schemer's valid_type
|
|
10
|
+
TESTS = {
|
|
11
|
+
"null" => :nil?.to_proc,
|
|
12
|
+
"boolean" => ->(data) { data == true || data == false },
|
|
13
|
+
"number" => ->(data) { data.is_a?(Numeric) },
|
|
14
|
+
"integer" => ->(data) { JSONSchemer::Draft202012::Vocab::Validation::Type.valid_integer?(data) },
|
|
15
|
+
"string" => ->(data) { data.is_a?(String) },
|
|
16
|
+
"array" => ->(data) { data.is_a?(Array) },
|
|
17
|
+
"object" => ->(data) { data.is_a?(Hash) }
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
# Types that are a class test, checked without a lambda when a schema allows only that type
|
|
21
|
+
CLASSES = {"number" => Numeric, "string" => String, "array" => Array, "object" => Hash}.freeze
|
|
22
|
+
|
|
23
|
+
# Draft 4 and OpenAPI 3.0 (based on it) accept only Integers; later versions also accept 1.0
|
|
24
|
+
INTEGER_ONLY = %i[draft4 openapi30].freeze
|
|
25
|
+
|
|
26
|
+
# JSON Schema versions where nullable: true adds "null" to type (json_schemer's OpenAPI30::Vocab::Base::Type)
|
|
27
|
+
NULLABLE_TYPE = %i[openapi30].freeze
|
|
28
|
+
|
|
29
|
+
# Integer test of draft 4 and OpenAPI 3.0
|
|
30
|
+
STRICT_INTEGER = ->(data) { data.is_a?(Integer) }
|
|
31
|
+
|
|
32
|
+
# @return [Array<String>] keywords this check owns
|
|
33
|
+
def self.keywords = %w[type]
|
|
34
|
+
|
|
35
|
+
# Reads config.json_schema_version; checkers are cached per class, and setting it resets them
|
|
36
|
+
#
|
|
37
|
+
# @param schema [Hash] schema
|
|
38
|
+
# @param _compiler [Versponse::Validator::Compiler] unused
|
|
39
|
+
# @return [TypeCheck, nil] the check, nil without type
|
|
40
|
+
# @raise [Versponse::Validator::Compiler::Unsupported] for an unknown type
|
|
41
|
+
def self.build(schema, _compiler)
|
|
42
|
+
return unless schema.key?("type")
|
|
43
|
+
|
|
44
|
+
types = Array(schema["type"])
|
|
45
|
+
types = (types + ["null"]).uniq if schema["nullable"] == true && NULLABLE_TYPE.include?(Versponse.configuration.json_schema_version)
|
|
46
|
+
single = types.map { |type| single(type) }
|
|
47
|
+
return single.first if types.one? && single.first
|
|
48
|
+
return NullableCheck.new(single[types.index { |type| type != "null" }]) if types.size == 2 && types.include?("null") && single.compact.one?
|
|
49
|
+
|
|
50
|
+
new(types.map { |type| test_for(type) })
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param type [String] one JSON Schema type
|
|
54
|
+
# @return [#valid?, nil] a check of only that type, nil for null and unknown types
|
|
55
|
+
def self.single(type)
|
|
56
|
+
return ClassCheck.new(CLASSES.fetch(type)) if CLASSES.key?(type)
|
|
57
|
+
return BooleanCheck.new if type == "boolean"
|
|
58
|
+
return unless type == "integer"
|
|
59
|
+
|
|
60
|
+
INTEGER_ONLY.include?(Versponse.configuration.json_schema_version) ? ClassCheck.new(Integer) : IntegerCheck.new
|
|
61
|
+
end
|
|
62
|
+
private_class_method :single
|
|
63
|
+
|
|
64
|
+
def self.test_for(type)
|
|
65
|
+
return STRICT_INTEGER if type == "integer" && INTEGER_ONLY.include?(Versponse.configuration.json_schema_version)
|
|
66
|
+
|
|
67
|
+
TESTS.fetch(type) { raise Compiler::Unsupported, "type #{type}" }
|
|
68
|
+
end
|
|
69
|
+
private_class_method :test_for
|
|
70
|
+
|
|
71
|
+
# @param tests [Array<Proc>] tests of the allowed types
|
|
72
|
+
def initialize(tests)
|
|
73
|
+
@tests = tests.freeze
|
|
74
|
+
freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param data [Object] value to check
|
|
78
|
+
# @return [Boolean] true when data has one of the types
|
|
79
|
+
def valid?(data)
|
|
80
|
+
@tests.any? { |test| test.call(data) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|