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,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Steps
5
+ # Renders a nested object, or each item of a collection, with a child plan
6
+ #
7
+ # @api private
8
+ class Nested
9
+ # @return [String] output key
10
+ attr_reader :key
11
+
12
+ # @param key [String] output key
13
+ # @param extractor [#call] value reader
14
+ # @param condition [Versponse::Condition, nil] if:/unless:
15
+ # @param plan [#call] child plan
16
+ # @param collection [Boolean] render each item
17
+ # @param collapsed_plan [#call, nil] plan used when the association is expandable and not expanded
18
+ # @param collapse_if [#call, nil] the referenced identifier's condition; when it is false for the value
19
+ # (for a collection: for any item) the full plan is used instead of collapsed_plan
20
+ # @param nil_fails [Boolean] in a validating plan, a nil value fails its schema (see InlineValidation)
21
+ # @param context_override [#call, nil] computes the subtree's context from (object, context)
22
+ def initialize(key:, extractor:, condition:, plan:, collection:, collapsed_plan: nil, collapse_if: nil, nil_fails: false, context_override: nil)
23
+ @key = key
24
+ @extractor = extractor
25
+ @condition = condition
26
+ @plan = plan
27
+ @collection = collection
28
+ @collapsed_plan = collapsed_plan
29
+ @collapse_if = collapse_if
30
+ @nil_fails = nil_fails
31
+ @context_override = context_override
32
+ freeze
33
+ end
34
+
35
+ # @return [Boolean] true with if:/unless:
36
+ def conditional?
37
+ !@condition.nil?
38
+ end
39
+
40
+ # @return [Boolean] true when each item of a collection is rendered
41
+ def collection?
42
+ @collection
43
+ end
44
+
45
+ # @return [Array<#call>] the child plan, and the collapsed plan when there is one
46
+ def plans
47
+ [@plan, @collapsed_plan].compact
48
+ end
49
+
50
+ # @param plan [#call] child plan
51
+ # @param collapsed_plan [#call, nil] plan used when collapsed
52
+ # @param nil_fails [Boolean] a nil value fails validation (validating plans only)
53
+ # @return [Nested] the same step rendering with these plans
54
+ def with_plans(plan:, collapsed_plan:, nil_fails: false)
55
+ self.class.new(key: @key, extractor: @extractor, condition: @condition, plan:, collection: @collection,
56
+ collapsed_plan:, collapse_if: @collapse_if, nil_fails:, context_override: @context_override)
57
+ end
58
+
59
+ # @param object [Object] source
60
+ # @param hash [Boolean] whether the source is a Hash
61
+ # @param context [Hash] serialize context
62
+ # @param output [Hash] Hash being built
63
+ # @param expansion [Versponse::Expansion] expand tree node of the parent
64
+ # @return [void]
65
+ # @raise [Versponse::ExtractionError] with the item index in its keys when a collection item fails
66
+ def call(object, hash, context, output, expansion)
67
+ return if @condition && !@condition.call(object, context)
68
+
69
+ value = @extractor.call(object, hash, context)
70
+ output[@key] = if value.nil?
71
+ InlineValidation.failed! if @nil_fails
72
+ nil
73
+ else
74
+ context = @context_override.call(object, context) if @context_override
75
+ # Errors from the child plans are marked, so only an explicit association policy recovers them
76
+ begin
77
+ collapse = @collapsed_plan && !expansion.expand?(@key) && (@collapse_if.nil? || collapse?(value, context))
78
+ plan = collapse ? @collapsed_plan : @plan
79
+ node = expansion.child(@key)
80
+ @collection ? Collections.map(Collections.items(value)) { |item| plan.call(item, context, node) } : plan.call(value, context, node)
81
+ rescue => e
82
+ ErrorPolicy.mark_nested!(e)
83
+ raise
84
+ end
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ # A collection collapses only when every item passes, so its items share one shape
91
+ def collapse?(value, context)
92
+ return @collapse_if.call(value, context) unless @collection
93
+
94
+ Collections.items(value).all? { |item| @collapse_if.call(item, context) }
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Steps
5
+ # Renders a value with the plan of the variant its dispatcher picks
6
+ #
7
+ # @api private
8
+ class OneOf
9
+ # @return [String] output key
10
+ attr_reader :key
11
+
12
+ # @param key [String] output key
13
+ # @param extractor [#call] value reader
14
+ # @param condition [Versponse::Condition, nil] if:/unless:
15
+ # @param dispatcher [Versponse::VariantDispatcher] variant matching
16
+ # @param collapsed_dispatcher [Versponse::VariantDispatcher, nil] matching to collapsed variants (expandable one_of)
17
+ # @param context_override [#call, nil] computes the variants' context from (object, context)
18
+ def initialize(key:, extractor:, condition:, dispatcher:, collapsed_dispatcher: nil, context_override: nil)
19
+ @key = key
20
+ @extractor = extractor
21
+ @condition = condition
22
+ @dispatcher = dispatcher
23
+ @collapsed_dispatcher = collapsed_dispatcher
24
+ @context_override = context_override
25
+ freeze
26
+ end
27
+
28
+ # @return [Boolean] true with if:/unless:
29
+ def conditional?
30
+ !@condition.nil?
31
+ end
32
+
33
+ # @param object [Object] source
34
+ # @param hash [Boolean] whether the source is a Hash
35
+ # @param context [Hash] serialize context
36
+ # @param output [Hash] Hash being built
37
+ # @param expansion [Versponse::Expansion] expand tree node of the parent
38
+ # @return [void]
39
+ # @raise [KeyError] when no variant matches (the plan wraps it in an ExtractionError)
40
+ def call(object, hash, context, output, expansion)
41
+ return if @condition && !@condition.call(object, context)
42
+
43
+ value = @extractor.call(object, hash, context)
44
+ return output[@key] = nil if value.nil?
45
+
46
+ context = @context_override.call(object, context) if @context_override
47
+ dispatcher = (@collapsed_dispatcher && !expansion.expand?(@key)) ? @collapsed_dispatcher : @dispatcher
48
+ output[@key] = dispatch(dispatcher, value, context, expansion.child(@key))
49
+ end
50
+
51
+ private
52
+
53
+ # Errors from the variant plans (and a value matching no variant) are marked like a nested step's
54
+ def dispatch(dispatcher, value, context, node)
55
+ dispatcher.call(value, context, node)
56
+ rescue => e
57
+ ErrorPolicy.mark_nested!(e)
58
+ raise
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Steps
5
+ # A plain field: reads a method or Hash key and formats it, with no condition, block or path.
6
+ # The common case, without Value's extractor and condition layers.
7
+ #
8
+ # @api private
9
+ class Read
10
+ # @return [String] output key
11
+ attr_reader :key
12
+
13
+ # @param key [String] output key
14
+ # @param name [String, Symbol] method or Hash key to read
15
+ # @param formatter [#call, nil] applied to non-nil values
16
+ def initialize(key:, name:, formatter: nil)
17
+ @key = key
18
+ @symbol = name.to_sym
19
+ @string = name.to_s.freeze
20
+ @formatter = formatter
21
+ freeze
22
+ end
23
+
24
+ # @param check [#valid?] checker of the property's schema
25
+ # @param recheck [Boolean] check an unfrozen value again after the render
26
+ # @return [CheckedRead] the same read, checking what it writes
27
+ def checked(check, recheck:)
28
+ CheckedRead.new(key: @key, name: @string, formatter: @formatter, check:, recheck:)
29
+ end
30
+
31
+ # @return [Boolean] false: a read has no if:/unless:
32
+ def conditional?
33
+ false
34
+ end
35
+
36
+ # @param object [Object] source
37
+ # @param hash [Boolean] whether the source is a Hash
38
+ # @param _context [Hash] unused
39
+ # @param output [Hash] Hash being built
40
+ # @param _expansion [Versponse::Expansion] unused
41
+ # @return [void]
42
+ # @raise [KeyError] when a Hash has neither key; NoMethodError when an object has no such method
43
+ def call(object, hash, _context, output, _expansion)
44
+ output[@key] = hash ? object.fetch(@symbol) { object.fetch(@string) } : object.public_send(@symbol)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Steps
5
+ # A plain field with a formatter, applied to non-nil values
6
+ #
7
+ # @api private
8
+ class ReadFormatted < Read
9
+ # @param check [#valid?] checker of the property's schema
10
+ # @param recheck [Boolean] check an unfrozen value again after the render
11
+ # @return [CheckedReadFormatted] the same read, checking the formatted value it writes
12
+ def checked(check, recheck:)
13
+ CheckedReadFormatted.new(key: @key, name: @string, formatter: @formatter, check:, recheck:)
14
+ end
15
+
16
+ # @param object [Object] source
17
+ # @param hash [Boolean] whether the source is a Hash
18
+ # @param _context [Hash] unused
19
+ # @param output [Hash] Hash being built
20
+ # @param _expansion [Versponse::Expansion] unused
21
+ # @return [void]
22
+ def call(object, hash, _context, output, _expansion)
23
+ value = hash ? object.fetch(@symbol) { object.fetch(@string) } : object.public_send(@symbol)
24
+ output[@key] = value.nil? ? nil : @formatter.call(value)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Compiled operations a plan runs for each property
5
+ module Steps
6
+ # Writes one extracted value, formatted when a formatter is bound
7
+ #
8
+ # @api private
9
+ class Value
10
+ # @return [String] output key
11
+ attr_reader :key
12
+
13
+ # @param key [String] output key
14
+ # @param extractor [#call] value reader
15
+ # @param condition [Versponse::Condition, nil] if:/unless:
16
+ # @param formatter [#call, nil] applied to non-nil values
17
+ def initialize(key:, extractor:, condition:, formatter:)
18
+ @key = key
19
+ @extractor = extractor
20
+ @condition = condition
21
+ @formatter = formatter
22
+ freeze
23
+ end
24
+
25
+ # @param check [#valid?] checker of the property's schema
26
+ # @param recheck [Boolean] check an unfrozen value again after the render
27
+ # @return [CheckedValue] the same step, checking what it writes
28
+ def checked(check, recheck:)
29
+ CheckedValue.new(key: @key, extractor: @extractor, condition: @condition, formatter: @formatter, check:, recheck:)
30
+ end
31
+
32
+ # @return [Boolean] true with if:/unless:
33
+ def conditional?
34
+ !@condition.nil?
35
+ end
36
+
37
+ # @param object [Object] source
38
+ # @param hash [Boolean] whether the source is a Hash
39
+ # @param context [Hash] serialize context
40
+ # @param output [Hash] Hash being built
41
+ # @param _expansion [Versponse::Expansion] unused
42
+ # @return [void]
43
+ def call(object, hash, context, output, _expansion)
44
+ return if @condition && !@condition.call(object, context)
45
+
46
+ value = @extractor.call(object, hash, context)
47
+ value = @formatter.call(value) if @formatter && !value.nil?
48
+ output[@key] = value
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Inclusive range of versions a response class serves, declared with supported_versions.
5
+ # Versions are compared with config.version_resolver, so the range uses the same order as
6
+ # resolution (string order by default, where "preview" sorts after dated versions).
7
+ #
8
+ # @api private
9
+ class SupportedVersions
10
+ # @return [String, nil] first supported version, nil to start at the first declared version
11
+ attr_reader :min
12
+ # @return [String, nil] last supported version, nil for no upper bound
13
+ attr_reader :max
14
+
15
+ # The resolver picks earlier for a request of later only when earlier sorts at or before it
16
+ #
17
+ # @param earlier [String] version expected first
18
+ # @param later [String] version expected second
19
+ # @return [Boolean] true when earlier sorts at or before later
20
+ def self.at_or_before?(earlier, later)
21
+ Versponse.configuration.version_resolver.call(later, [earlier]) == earlier
22
+ end
23
+
24
+ # @param min [String, nil] first supported version (inclusive)
25
+ # @param max [String, nil] last supported version (inclusive)
26
+ # @raise [Versponse::DefinitionError] when min sorts after max
27
+ def initialize(min:, max:)
28
+ @min = min&.to_s
29
+ @max = max&.to_s
30
+ raise DefinitionError, "supported_versions min: #{@min} is after max: #{@max}" if @min && @max && !self.class.at_or_before?(@min, @max)
31
+
32
+ freeze
33
+ end
34
+
35
+ # Without min, earlier versions are left to the resolver, which finds nothing before the first declared one
36
+ #
37
+ # @param version [String] version name
38
+ # @return [Boolean] true when the range includes the version
39
+ def covers?(version)
40
+ (min.nil? || self.class.at_or_before?(min, version)) && (max.nil? || self.class.at_or_before?(version, max))
41
+ end
42
+
43
+ # Only max is checked: declared blocks don't have to be inside the range, so min: "preview"
44
+ # with a single version "2025-09" block serves "preview" from 2025-09
45
+ #
46
+ # @param declared [Array<String>] declared version names, oldest first
47
+ # @param owner [String] class name, for the message
48
+ # @return [void]
49
+ # @raise [Versponse::DefinitionError] when max sorts before every declared version, so no request could resolve
50
+ def check!(declared, owner)
51
+ return if max.nil? || declared.empty? || declared.any? { |version| self.class.at_or_before?(version, max) }
52
+
53
+ raise DefinitionError, "#{owner}: supported_versions max: #{max} is before every declared version (#{declared.join(", ")})"
54
+ end
55
+
56
+ # @return [String] the range for messages, like "min: 2025-09, max: none"
57
+ def to_s
58
+ "min: #{min || "first declared"}, max: #{max || "none"}"
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Validator
5
+ # Combines the checkers of one schema; all must pass
6
+ #
7
+ # @api private
8
+ class All
9
+ # @param checkers [Array<#valid?>] checkers of one schema
10
+ # @return [#valid?] VALID for none, the checker itself for one, else an All
11
+ def self.for(checkers)
12
+ return Constant::VALID if checkers.empty?
13
+ return checkers.first if checkers.one?
14
+ return Both.new(*checkers) if checkers.size == 2
15
+
16
+ new(checkers)
17
+ end
18
+
19
+ # @param checkers [Array<#valid?>] two or more checkers
20
+ def initialize(checkers)
21
+ @checkers = checkers.freeze
22
+ freeze
23
+ end
24
+
25
+ # @param data [Object] value to check
26
+ # @return [Boolean] true when every checker passes
27
+ def valid?(data)
28
+ @checkers.all? { |checker| checker.valid?(data) }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Validator
5
+ # items, minItems and maxItems
6
+ #
7
+ # @api private
8
+ class ArrayCheck
9
+ # @return [Array<String>] keywords this check owns
10
+ def self.keywords = %w[items minItems maxItems]
11
+
12
+ # @return [Class] the class a type folded into this check requires
13
+ def self.shape = Array
14
+
15
+ # @param schema [Hash] schema
16
+ # @param compiler [Versponse::Validator::Compiler] compiles the items schema
17
+ # @return [ArrayCheck, nil] the check, nil without array keywords
18
+ def self.build(schema, compiler)
19
+ return unless keywords.any? { |keyword| schema.key?(keyword) }
20
+
21
+ new(compiler.compile(schema.fetch("items", true)), Range.new(schema["minItems"], schema["maxItems"]))
22
+ end
23
+
24
+ # @param items [#valid?] checker of every item
25
+ # @param sizes [Range] allowed number of items
26
+ # @param strict [Boolean] true when anything but an Array fails (type: "array" folded in)
27
+ def initialize(items, sizes, strict: false)
28
+ @items = items
29
+ @sizes = sizes
30
+ @strict = strict
31
+ freeze
32
+ end
33
+
34
+ # @return [ArrayCheck] the same check, failing anything but an Array
35
+ def strict
36
+ self.class.new(@items, @sizes, strict: true)
37
+ end
38
+
39
+ # @param data [Object] value to check; anything but an Array passes unless strict
40
+ # @return [Boolean] true when the Array matches
41
+ def valid?(data)
42
+ return !@strict unless data.is_a?(Array)
43
+
44
+ @sizes.cover?(data.size) && data.all? { |item| @items.valid?(item) }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Validator
5
+ # type: boolean, compared with == as json_schemer does
6
+ #
7
+ # @api private
8
+ class BooleanCheck
9
+ # @param data [Object] value to check
10
+ # @return [Boolean] true when data is true or false
11
+ def valid?(data)
12
+ data == true || data == false
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Validator
5
+ # Two checkers, called without an Array loop (the common case: a type and one other check)
6
+ #
7
+ # @api private
8
+ class Both
9
+ # @param first [#valid?] first checker
10
+ # @param second [#valid?] second checker
11
+ def initialize(first, second)
12
+ @first = first
13
+ @second = second
14
+ freeze
15
+ end
16
+
17
+ # @param data [Object] value to check
18
+ # @return [Boolean] true when both checkers accept data
19
+ def valid?(data)
20
+ @first.valid?(data) && @second.valid?(data)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Validator
5
+ # A type check that is one class test (only number, string, array, object, or integer where only Integers count)
6
+ #
7
+ # @api private
8
+ class ClassCheck
9
+ # @return [Class] the class data must be
10
+ attr_reader :klass
11
+
12
+ # @param klass [Class] the class data must be
13
+ def initialize(klass)
14
+ @klass = klass
15
+ freeze
16
+ end
17
+
18
+ # @param data [Object] value to check
19
+ # @return [Boolean] true when data is of the class
20
+ def valid?(data)
21
+ data.is_a?(@klass)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Validator
5
+ # An object with additionalProperties: false whose required keys are all declared properties: each declared key
6
+ # is fetched and checked, and a count of the present ones equal to the Hash size proves there are no others
7
+ #
8
+ # @api private
9
+ class ClosedObjectCheck
10
+ # Marks a declared key the Hash doesn't have
11
+ MISSING = Object.new.freeze
12
+ private_constant :MISSING
13
+
14
+ # @return [Class] the class a type folded into this check requires
15
+ def self.shape = Hash
16
+
17
+ # @param properties [Hash{String => #valid?}] checkers of the declared properties
18
+ # @param required [Array<String>] required keys, all of them declared properties
19
+ # @param sizes [Range] allowed number of properties
20
+ # @param strict [Boolean] true when anything but a Hash fails (type: "object" folded in)
21
+ def initialize(properties:, required:, sizes:, strict: false)
22
+ @properties = properties
23
+ @required_keys = required
24
+ @keys = properties.keys.freeze
25
+ @checks = properties.values.freeze
26
+ @required = @keys.map { |key| required.include?(key) }.freeze
27
+ @sizes = sizes
28
+ @strict = strict
29
+ freeze
30
+ end
31
+
32
+ # @return [ClosedObjectCheck] the same check, failing anything but a Hash
33
+ def strict
34
+ self.class.new(properties: @properties, required: @required_keys, sizes: @sizes, strict: true)
35
+ end
36
+
37
+ # @param data [Object] value to check; anything but a Hash passes unless strict
38
+ # @return [Boolean] true when the Hash matches
39
+ def valid?(data)
40
+ return !@strict unless data.is_a?(Hash)
41
+
42
+ found = 0
43
+ index = 0
44
+ while index < @keys.size
45
+ value = data.fetch(@keys[index], MISSING)
46
+ if MISSING.equal?(value)
47
+ return reject(data) if @required[index]
48
+ else
49
+ return reject(data) unless @checks[index].valid?(value)
50
+
51
+ found += 1
52
+ end
53
+ index += 1
54
+ end
55
+ (found == data.size && @sizes.cover?(found)) || reject(data)
56
+ end
57
+
58
+ private
59
+
60
+ # json_schemer reads Symbol keys as Strings, so a false is certain only for a Hash with String keys; one with
61
+ # Symbol keys is checked again with their Strings, and any other Hash throws {Root::DEFER}
62
+ #
63
+ # @param data [Hash] value being checked
64
+ # @return [Boolean] false, or the answer for the Hash with String keys
65
+ def reject(data)
66
+ data.each_key do |key|
67
+ next if key.is_a?(String)
68
+
69
+ strings = StringKeys.call(data)
70
+ throw Root::DEFER unless strings
71
+
72
+ return valid?(strings)
73
+ end
74
+ false
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Validator
5
+ # anyOf, oneOf and allOf
6
+ #
7
+ # @api private
8
+ class Combinator
9
+ # Keywords and the Enumerable predicate each applies to its branches
10
+ MODES = {"anyOf" => :any?, "oneOf" => :one?, "allOf" => :all?}.freeze
11
+
12
+ # @return [Array<String>] keywords this check owns
13
+ def self.keywords = MODES.keys
14
+
15
+ # @param schema [Hash] schema
16
+ # @param compiler [Versponse::Validator::Compiler] compiles the branches
17
+ # @return [#valid?, nil] the check, nil without combinators
18
+ def self.build(schema, compiler)
19
+ combinators = MODES.filter_map do |keyword, mode|
20
+ new(schema[keyword].map { |branch| compiler.compile(branch) }, mode) if schema.key?(keyword)
21
+ end
22
+ All.for(combinators) unless combinators.empty?
23
+ end
24
+
25
+ # @param branches [Array<#valid?>] checkers of the branches
26
+ # @param mode [Symbol] :any?, :one? or :all?
27
+ def initialize(branches, mode)
28
+ @branches = branches.freeze
29
+ @mode = mode
30
+ freeze
31
+ end
32
+
33
+ # @param data [Object] value to check
34
+ # @return [Boolean] true when the branches match as the mode requires
35
+ def valid?(data)
36
+ @branches.public_send(@mode) { |branch| branch.valid?(data) }
37
+ end
38
+ end
39
+ end
40
+ end