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.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +15 -6
  3. data/CHANGELOG.md +59 -1
  4. data/CODE_OF_CONDUCT.md +1 -1
  5. data/LICENSE.txt +1 -1
  6. data/README.md +674 -20
  7. data/Rakefile +26 -3
  8. data/lib/versponse/base/class_methods.rb +180 -0
  9. data/lib/versponse/base/dsl.rb +231 -0
  10. data/lib/versponse/base.rb +35 -0
  11. data/lib/versponse/builder.rb +423 -0
  12. data/lib/versponse/cache.rb +47 -0
  13. data/lib/versponse/callable.rb +74 -0
  14. data/lib/versponse/class_policies.rb +15 -0
  15. data/lib/versponse/collapse_if_plan.rb +26 -0
  16. data/lib/versponse/collections.rb +49 -0
  17. data/lib/versponse/components.rb +37 -0
  18. data/lib/versponse/condition.rb +33 -0
  19. data/lib/versponse/configuration.rb +261 -0
  20. data/lib/versponse/const_as_enum.rb +39 -0
  21. data/lib/versponse/context_override/call.rb +27 -0
  22. data/lib/versponse/context_override/merge.rb +23 -0
  23. data/lib/versponse/context_override.rb +25 -0
  24. data/lib/versponse/context_plan.rb +24 -0
  25. data/lib/versponse/deep_copy.rb +27 -0
  26. data/lib/versponse/deep_freeze.rb +20 -0
  27. data/lib/versponse/discriminated_plan.rb +26 -0
  28. data/lib/versponse/error_policy/recovery.rb +68 -0
  29. data/lib/versponse/error_policy.rb +97 -0
  30. data/lib/versponse/expansion.rb +46 -0
  31. data/lib/versponse/extractors/block.rb +24 -0
  32. data/lib/versponse/extractors/fallback.rb +27 -0
  33. data/lib/versponse/extractors/itself.rb +21 -0
  34. data/lib/versponse/extractors/key.rb +28 -0
  35. data/lib/versponse/extractors/path.rb +28 -0
  36. data/lib/versponse/extractors.rb +23 -0
  37. data/lib/versponse/field_order.rb +23 -0
  38. data/lib/versponse/field_type.rb +88 -0
  39. data/lib/versponse/formatters.rb +38 -0
  40. data/lib/versponse/gem_version.rb +6 -0
  41. data/lib/versponse/inline_validation/builder.rb +129 -0
  42. data/lib/versponse/inline_validation/state.rb +16 -0
  43. data/lib/versponse/inline_validation.rb +86 -0
  44. data/lib/versponse/internals.rb +553 -0
  45. data/lib/versponse/json_value.rb +22 -0
  46. data/lib/versponse/lazy_plan.rb +30 -0
  47. data/lib/versponse/naming.rb +68 -0
  48. data/lib/versponse/object_schema.rb +25 -0
  49. data/lib/versponse/open_api/builder.rb +159 -0
  50. data/lib/versponse/open_api/component_set.rb +33 -0
  51. data/lib/versponse/open_api/declarations.rb +90 -0
  52. data/lib/versponse/open_api/document.rb +187 -0
  53. data/lib/versponse/open_api/ref_check.rb +89 -0
  54. data/lib/versponse/open_api/rswag_specs.rb +98 -0
  55. data/lib/versponse/open_api/sources.rb +72 -0
  56. data/lib/versponse/open_api/values.rb +29 -0
  57. data/lib/versponse/open_api.rb +12 -0
  58. data/lib/versponse/output_validator.rb +82 -0
  59. data/lib/versponse/pattern.rb +38 -0
  60. data/lib/versponse/plan.rb +104 -0
  61. data/lib/versponse/properties/array.rb +56 -0
  62. data/lib/versponse/properties/base.rb +325 -0
  63. data/lib/versponse/properties/collection.rb +135 -0
  64. data/lib/versponse/properties/const.rb +46 -0
  65. data/lib/versponse/properties/container.rb +51 -0
  66. data/lib/versponse/properties/enum.rb +93 -0
  67. data/lib/versponse/properties/field.rb +43 -0
  68. data/lib/versponse/properties/item_one_of.rb +19 -0
  69. data/lib/versponse/properties/object.rb +32 -0
  70. data/lib/versponse/properties/one_of.rb +176 -0
  71. data/lib/versponse/properties/reference.rb +133 -0
  72. data/lib/versponse/references.rb +162 -0
  73. data/lib/versponse/registry.rb +124 -0
  74. data/lib/versponse/schema_context.rb +69 -0
  75. data/lib/versponse/schema_merge.rb +24 -0
  76. data/lib/versponse/selection/scope.rb +36 -0
  77. data/lib/versponse/selection.rb +92 -0
  78. data/lib/versponse/selection_schema.rb +101 -0
  79. data/lib/versponse/steps/checked.rb +43 -0
  80. data/lib/versponse/steps/checked_read.rb +46 -0
  81. data/lib/versponse/steps/checked_read_formatted.rb +32 -0
  82. data/lib/versponse/steps/checked_value.rb +48 -0
  83. data/lib/versponse/steps/const.rb +44 -0
  84. data/lib/versponse/steps/nested.rb +98 -0
  85. data/lib/versponse/steps/one_of.rb +62 -0
  86. data/lib/versponse/steps/read.rb +48 -0
  87. data/lib/versponse/steps/read_formatted.rb +28 -0
  88. data/lib/versponse/steps/value.rb +52 -0
  89. data/lib/versponse/supported_versions.rb +61 -0
  90. data/lib/versponse/validator/all.rb +32 -0
  91. data/lib/versponse/validator/array_check.rb +48 -0
  92. data/lib/versponse/validator/boolean_check.rb +16 -0
  93. data/lib/versponse/validator/both.rb +24 -0
  94. data/lib/versponse/validator/class_check.rb +25 -0
  95. data/lib/versponse/validator/closed_object_check.rb +78 -0
  96. data/lib/versponse/validator/combinator.rb +40 -0
  97. data/lib/versponse/validator/compiler.rb +146 -0
  98. data/lib/versponse/validator/constant.rb +33 -0
  99. data/lib/versponse/validator/date_time_check.rb +21 -0
  100. data/lib/versponse/validator/format_check.rb +49 -0
  101. data/lib/versponse/validator/integer_check.rb +16 -0
  102. data/lib/versponse/validator/nullable_check.rb +22 -0
  103. data/lib/versponse/validator/number_check.rb +58 -0
  104. data/lib/versponse/validator/object_check.rb +90 -0
  105. data/lib/versponse/validator/ref.rb +38 -0
  106. data/lib/versponse/validator/root.rb +31 -0
  107. data/lib/versponse/validator/string_check.rb +48 -0
  108. data/lib/versponse/validator/string_keys.rb +21 -0
  109. data/lib/versponse/validator/type_check.rb +84 -0
  110. data/lib/versponse/validator/value_check.rb +36 -0
  111. data/lib/versponse/validator.rb +10 -0
  112. data/lib/versponse/variant_dispatcher.rb +35 -0
  113. data/lib/versponse/version.rb +179 -1
  114. data/lib/versponse/version_resolver.rb +27 -0
  115. data/lib/versponse/versions.rb +116 -0
  116. data/lib/versponse/view.rb +126 -0
  117. data/lib/versponse/wrapper.rb +53 -0
  118. data/lib/versponse.rb +165 -3
  119. metadata +151 -12
  120. 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