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,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Frozen tree of requested expand paths, passed down while rendering. Each node answers whether
5
+ # a key is expanded and hands out the node for that key's children.
6
+ #
7
+ # @api private
8
+ class Expansion
9
+ # @param paths [Array<String, Symbol>] dotted paths
10
+ # @return [Expansion] the tree (NONE for no paths)
11
+ def self.build(paths)
12
+ tree = {}
13
+ paths.map(&:to_s).each { |path| path.split(".").reduce(tree) { |node, key| node[key] ||= {} } }
14
+ from_hash(tree)
15
+ end
16
+
17
+ # @param hash [Hash{String => Hash}] nested keys
18
+ # @return [Expansion] the frozen node
19
+ def self.from_hash(hash)
20
+ return NONE if hash.empty?
21
+
22
+ new(hash.transform_values { |child| from_hash(child) })
23
+ end
24
+
25
+ # @param children [Hash{String => Expansion}] expanded keys and their nodes
26
+ def initialize(children = {})
27
+ @children = children.freeze
28
+ freeze
29
+ end
30
+
31
+ # @param key [String] output key
32
+ # @return [Boolean] whether the association under key is expanded
33
+ def expand?(key)
34
+ @children.key?(key)
35
+ end
36
+
37
+ # @param key [String] output key
38
+ # @return [Expansion] the node for the key's children
39
+ def child(key)
40
+ @children.fetch(key, NONE)
41
+ end
42
+
43
+ # Nothing expanded
44
+ NONE = new
45
+ end
46
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Extractors
5
+ # Calls a value block with (object, context)
6
+ #
7
+ # @api private
8
+ class Block
9
+ # @param block [Proc] value block
10
+ def initialize(block)
11
+ @block = block
12
+ freeze
13
+ end
14
+
15
+ # @param object [Object] source
16
+ # @param _hash [Boolean] unused
17
+ # @param context [Hash] serialize context
18
+ # @return [Object] the block's result
19
+ def call(object, _hash, context)
20
+ @block.call(object, context)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Extractors
5
+ # Reads with another extractor and falls back to a callable when the value is nil (default:, on_nil)
6
+ #
7
+ # @api private
8
+ class Fallback
9
+ # @param extractor [#call] value reader
10
+ # @param fallback [#call] called with (object, context) for a nil value
11
+ def initialize(extractor, fallback)
12
+ @extractor = extractor
13
+ @fallback = fallback
14
+ freeze
15
+ end
16
+
17
+ # @param object [Object] source
18
+ # @param hash [Boolean] whether the source is a Hash
19
+ # @param context [Hash] serialize context
20
+ # @return [Object] the value, or the fallback's for nil
21
+ def call(object, hash, context)
22
+ value = @extractor.call(object, hash, context)
23
+ value.nil? ? @fallback.call(object, context) : value
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Extractors
5
+ # Returns the source object (map: ".")
6
+ #
7
+ # @api private
8
+ class Itself
9
+ # Shared instance
10
+ INSTANCE = new.freeze
11
+
12
+ # @param object [Object] source
13
+ # @param _hash [Boolean] unused
14
+ # @param _context [Hash] unused
15
+ # @return [Object] the source object
16
+ def call(object, _hash, _context)
17
+ object
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Extractors
5
+ # Reads a method, or a Symbol then String key of a Hash
6
+ #
7
+ # @api private
8
+ class Key
9
+ # @param name [String, Symbol] method or key name
10
+ def initialize(name)
11
+ @symbol = name.to_sym
12
+ @string = name.to_s.freeze
13
+ freeze
14
+ end
15
+
16
+ # @param object [Object] source
17
+ # @param hash [Boolean] whether the source is a Hash
18
+ # @param _context [Hash] unused
19
+ # @return [Object] the value
20
+ # @raise [KeyError] when a Hash has neither key
21
+ def call(object, hash, _context)
22
+ return object.public_send(@symbol) unless hash
23
+
24
+ object.fetch(@symbol) { object.fetch(@string) }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module Extractors
5
+ # Reads a nested value like "address/city", returning nil when a step is nil
6
+ #
7
+ # @api private
8
+ class Path
9
+ # @param segments [Array<String>] path segments
10
+ def initialize(segments)
11
+ @keys = segments.map { |segment| Key.new(segment) }.freeze
12
+ freeze
13
+ end
14
+
15
+ # @param object [Object] source
16
+ # @param _hash [Boolean] unused, each step checks its own source
17
+ # @param context [Hash] serialize context
18
+ # @return [Object, nil] the value
19
+ def call(object, _hash, context)
20
+ @keys.reduce(object) do |current, key|
21
+ return nil if current.nil?
22
+
23
+ key.call(current, current.is_a?(Hash), context)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Value readers chosen once at compile time. Each is called with (object, hash, context),
5
+ # where hash tells whether the object is a Hash.
6
+ #
7
+ # @api private
8
+ module Extractors
9
+ # @param name [Symbol] property name
10
+ # @param map [String, nil] map: path
11
+ # @param block [Proc, nil] value block
12
+ # @return [#call] the extractor for the property
13
+ def self.for(name:, map:, block:)
14
+ return Block.new(Callable.normalize(block)) if block
15
+ return Key.new(name) if map.nil?
16
+ return Itself::INSTANCE if map == "."
17
+ raise DefinitionError, "map: #{map.inspect} must be a non-empty path without empty segments" if map.empty? || map.split("/", -1).any?(&:empty?)
18
+ return Path.new(map.split("/")) if map.include?("/")
19
+
20
+ Key.new(map)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Orders properties as config.field_order says
5
+ #
6
+ # @api private
7
+ module FieldOrder
8
+ # Values accepted by config.field_order
9
+ VALUES = %i[declaration alphabetical].freeze
10
+
11
+ # :alphabetical matches blueprinter's sort_fields_by :name_asc: identifiers first, then by output key
12
+ #
13
+ # @param properties [Array<Versponse::Properties::Base>] properties in declaration order
14
+ # @param identifiers [Array<String>] identifier keys, which go first when alphabetical
15
+ # @return [Array<Versponse::Properties::Base>] the properties in the configured order
16
+ def self.call(properties, identifiers: [])
17
+ return properties if Versponse.configuration.field_order == :declaration
18
+
19
+ first, rest = properties.partition { |property| identifiers.include?(property.key) }
20
+ first + rest.sort_by(&:key)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Built-in field types, and custom ones from config.custom_field_types in verquest's shape
5
+ #
6
+ # @api private
7
+ module FieldType
8
+ # Built-in types; "object" is a free-form map whose value passes through
9
+ BUILT_IN = %w[string number integer boolean object].freeze
10
+
11
+ # Keys of a custom type definition; required: is accepted for verquest and ignored
12
+ KEYS = %i[type schema_options required].freeze
13
+
14
+ # @param name [Symbol, String] custom type name
15
+ # @param definition [Hash] type: and schema_options: (required: is ignored)
16
+ # @return [Hash{String => Object}] frozen {"type" => base type, "schema_options" => camelized keywords}
17
+ # @raise [ArgumentError] for a built-in name, unknown keys or an unsupported type:
18
+ # @raise [Versponse::DefinitionError] for a Regexp pattern ECMA-262 can't express
19
+ def self.normalize(name, definition)
20
+ definition = check!(name.to_s, definition)
21
+ DeepFreeze.call({"type" => definition[:type].to_s, "schema_options" => Naming.camelize_keys(definition[:schema_options] || {})})
22
+ end
23
+
24
+ # @param type [Symbol, String, Array<Symbol, String>] built-in or custom type name, or a union of built-in types
25
+ # @param key [String] property key, for the error message
26
+ # @return [Array(String, Hash), Array(Array<String>, Hash)] the base type (or the union's types) and the custom type's schema options
27
+ # @raise [Versponse::DefinitionError] for an unknown type or an invalid union
28
+ def self.resolve(type, key)
29
+ return [union(type, key), {}] if type.is_a?(Array)
30
+
31
+ type = type.to_s
32
+ return [type, {}] if BUILT_IN.include?(type)
33
+
34
+ custom = Versponse.configuration.custom_field_types.fetch(type) do
35
+ raise DefinitionError, "#{key}: type must be one of #{names.join(", ")}"
36
+ end
37
+ custom.values_at("type", "schema_options")
38
+ end
39
+
40
+ # Null comes from nullable:, and custom types can't take part, because their schema options would apply to every type
41
+ #
42
+ # @param types [Array<Symbol, String>] two or more distinct built-in types
43
+ # @param key [String] property key, for the error message
44
+ # @return [Array<String>] frozen type names in declaration order, without duplicates
45
+ # @raise [Versponse::DefinitionError] for fewer than two distinct types, or a type that isn't built in
46
+ def self.union(types, key)
47
+ names = types.map(&:to_s).uniq
48
+ invalid = names - BUILT_IN
49
+ raise DefinitionError, "#{key}: type unions take only built-in types (#{BUILT_IN.join(", ")}), not #{invalid.join(", ")}" unless invalid.empty?
50
+ raise DefinitionError, "#{key}: a type union needs at least two distinct types" if names.size < 2
51
+
52
+ names.freeze
53
+ end
54
+
55
+ # The union's anyOf and a declared anyOf (or allOf) are combined by {SchemaMerge}
56
+ #
57
+ # OpenAPI 3.0's nullable only applies next to a type, so a nullable union sets it on each branch
58
+ #
59
+ # @param types [Array<String>] union types
60
+ # @param options [Hash{String => Object}] the property's (or its items') schema keywords
61
+ # @param nullable [Boolean] whether null is allowed
62
+ # @return [Hash] OpenAPI 3.0 schema: an anyOf of single types with the keywords alongside
63
+ def self.openapi30_union(types, options, nullable: false)
64
+ branch = nullable ? {"nullable" => true} : {}
65
+ SchemaMerge.call({"anyOf" => types.map { |type| {"type" => type}.merge(branch) }}, options)
66
+ end
67
+
68
+ # @return [Array<String>] built-in and custom type names
69
+ def self.names
70
+ BUILT_IN + Versponse.configuration.custom_field_types.keys
71
+ end
72
+
73
+ def self.check!(name, definition)
74
+ raise ArgumentError, "Custom field type #{name} shadows a built-in type" if BUILT_IN.include?(name)
75
+ raise ArgumentError, "Custom field type #{name} must be a Hash" unless definition.is_a?(Hash)
76
+
77
+ definition.transform_keys(&:to_sym).tap { |symbolized| check_keys!(name, symbolized) }
78
+ end
79
+
80
+ def self.check_keys!(name, definition)
81
+ unknown = definition.keys - KEYS
82
+ raise ArgumentError, "Custom field type #{name} has unknown keys: #{unknown.join(", ")}" unless unknown.empty?
83
+ raise ArgumentError, "Custom field type #{name} needs type: one of #{BUILT_IN.join(", ")}" unless BUILT_IN.include?(definition[:type].to_s)
84
+ end
85
+
86
+ private_class_method :check!, :check_keys!
87
+ end
88
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Default formatters, keyed by JSON Schema format, applied to non-nil field values
5
+ module Formatters
6
+ # Formats times, datetimes and dates (as midnight) as ISO 8601 in UTC with the given fraction digits,
7
+ # always with a Z suffix, without changing the original object. The Strings it builds are frozen, so inline
8
+ # validation checks them once (see InlineValidation::Builder); any other value is returned as it is.
9
+ #
10
+ # @param precision [Integer] fraction digits, 0 for whole seconds
11
+ # @return [Proc] date-time formatter
12
+ def self.date_time(precision)
13
+ lambda do |value|
14
+ # A UTC Time needs no getutc copy
15
+ if value.instance_of?(Time) && value.utc?
16
+ value.iso8601(precision).freeze
17
+ elsif value.respond_to?(:getutc)
18
+ value.getutc.iso8601(precision).freeze
19
+ elsif value.respond_to?(:new_offset)
20
+ value.new_offset(0).to_time.utc.iso8601(precision).freeze
21
+ elsif value.is_a?(Date)
22
+ Time.utc(value.year, value.month, value.day).iso8601(precision).freeze
23
+ else
24
+ value
25
+ end
26
+ end
27
+ end
28
+
29
+ # Formats date-times with milliseconds, like Rails' JSON
30
+ DATE_TIME = date_time(3)
31
+
32
+ # Formats dates, and times or datetimes (converted to their date), as frozen ISO 8601 Strings
33
+ DATE = ->(value) { (value.is_a?(Date) || value.is_a?(Time)) ? value.to_date.iso8601.freeze : value }
34
+
35
+ # Formatters used when none are configured
36
+ DEFAULTS = {"date-time" => DATE_TIME, "date" => DATE}.freeze
37
+ end
38
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Gem version (Versponse::Version is the version definition class)
5
+ GEM_VERSION = "0.2.0"
6
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module InlineValidation
5
+ # Turns a plan into one that validates while rendering, when its validation schema proves it can: the object
6
+ # keywords hold by construction (a plan writes exactly its steps' String keys, unconditional ones always), and
7
+ # each step checks its value. Nested plans are proven against their subschema in the same tree; anything
8
+ # unproven checks its written value with the subschema's compiled checker.
9
+ #
10
+ # @api private
11
+ class Builder
12
+ # Object keywords a plan guarantees by construction (type object, additionalProperties false)
13
+ OBJECT_KEYWORDS = %w[type properties required additionalProperties].freeze
14
+
15
+ # Types a change in place can't turn into another type (not integer: json_schemer accepts any Numeric whose
16
+ # floor equals it, so a mutable Numeric can stop passing)
17
+ SCALAR_TYPES = %w[string number boolean null].freeze
18
+
19
+ # Types whose values can't be changed in place at all
20
+ IMMUTABLE_TYPES = %w[boolean null].freeze
21
+
22
+ # Keywords that only constrain the type (nullable: openapi30)
23
+ TYPE_KEYWORDS = %w[type nullable].freeze
24
+
25
+ # Leaf steps that have a checking variant
26
+ LEAVES = [Steps::Read, Steps::ReadFormatted, Steps::Value].freeze
27
+
28
+ # Keywords that never change validity, besides x- extensions
29
+ INERT = (Validator::Compiler::ANNOTATIONS + Validator::Compiler::ANCHORS).freeze
30
+
31
+ # @param compiler [Versponse::Validator::Compiler] compiler that compiled the whole validation schema
32
+ def initialize(compiler)
33
+ @compiler = compiler
34
+ end
35
+
36
+ # @param plan [Versponse::Plan] plain plan
37
+ # @param schema [Hash] its object schema, from the compiled tree
38
+ # @return [Versponse::Plan, nil] the validating plan, nil when the schema doesn't prove it
39
+ def prove(plan, schema)
40
+ return unless object_schema?(schema)
41
+
42
+ properties = schema.fetch("properties", {})
43
+ keys = plan.steps.map(&:key)
44
+ return unless keys.uniq.size == keys.size && keys.sort == properties.keys.sort
45
+ return unless plan.steps.reject(&:conditional?).map(&:key).sort == schema.fetch("required", []).sort
46
+
47
+ steps = plan.steps.map { |step| checked(step, properties.fetch(step.key)) }
48
+ Plan.new(label: plan.label, steps:, recoveries: plan.recoveries, always: plan.always) unless steps.include?(nil)
49
+ rescue Validator::Compiler::Unsupported
50
+ nil
51
+ end
52
+
53
+ private
54
+
55
+ def object_schema?(schema)
56
+ schema.is_a?(Hash) && schema["type"] == "object" && schema["additionalProperties"] == false &&
57
+ schema.each_key.all? { |key| OBJECT_KEYWORDS.include?(key) || inert?(key) }
58
+ end
59
+
60
+ def inert?(key)
61
+ INERT.include?(key) || key.start_with?("x-")
62
+ end
63
+
64
+ # Only these keys, besides inert ones
65
+ def only?(schema, keys)
66
+ schema.is_a?(Hash) && schema.each_key.all? { |key| keys.include?(key) || inert?(key) }
67
+ end
68
+
69
+ # Exact classes only (a subclass may write other keys, so it stays unproven until it is listed here)
70
+ def checked(step, schema)
71
+ klass = step.class
72
+ if LEAVES.include?(klass) then step.checked(@compiler.checker_for(schema), recheck: recheck?(schema))
73
+ elsif klass == Steps::Const then step if root(schema).valid?(step.value)
74
+ elsif klass == Steps::Nested then nested(step, schema)
75
+ elsif klass == Steps::OneOf then Steps::Checked.new(step, root(schema))
76
+ end
77
+ end
78
+
79
+ # Whether a value the schema accepts can stop passing when it is changed in place later in the render: not
80
+ # when only its type is constrained and every type is scalar (a change in place keeps the value's class), nor
81
+ # for booleans and nulls
82
+ def recheck?(schema)
83
+ return true unless schema.is_a?(Hash) && schema.key?("type")
84
+
85
+ types = Array(schema["type"])
86
+ return false if types.all? { |type| IMMUTABLE_TYPES.include?(type) }
87
+
88
+ !(types.all? { |type| SCALAR_TYPES.include?(type) } && only?(schema, TYPE_KEYWORDS))
89
+ end
90
+
91
+ def root(schema)
92
+ Validator::Root.new(@compiler.checker_for(schema))
93
+ end
94
+
95
+ def nested(step, schema)
96
+ target = step.collection? ? items(schema) : schema
97
+ plans = step.plans.map { |plan| proven(plan, target) } if target
98
+ return Steps::Checked.new(step, root(schema)) if plans.nil? || plans.include?(nil)
99
+
100
+ step.with_plans(plan: plans[0], collapsed_plan: plans[1], nil_fails: !root(schema).valid?(nil))
101
+ end
102
+
103
+ # {"type" => "array" or ["array", "null"], "items" => I}: the items schema I
104
+ def items(schema)
105
+ return unless only?(schema, %w[type items]) && schema.key?("items")
106
+
107
+ schema["items"] if [["array"], %w[array null]].include?(Array(schema["type"]).sort)
108
+ end
109
+
110
+ # The schema itself, or a branch of an anyOf (any branch passing satisfies it)
111
+ def proven(plan, schema)
112
+ plan = resolve(plan)
113
+ return unless plan
114
+
115
+ prove(plan, schema) || (schema["anyOf"].lazy.filter_map { |branch| prove(plan, branch) }.first if only?(schema, %w[anyOf]) && schema["anyOf"].is_a?(Array))
116
+ end
117
+
118
+ # Class-backed and inline plans; dispatchers and other plan objects stay unproven
119
+ def resolve(plan)
120
+ case plan
121
+ when Plan then plan
122
+ when LazyPlan then plan.resolve
123
+ end
124
+ rescue Error
125
+ nil
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module InlineValidation
5
+ # A fiber's inline validation state, kept in one fiber-local so a render reads it once
6
+ #
7
+ # @api private
8
+ class State
9
+ # @return [Boolean, nil] whether a check failed in the current run, nil outside a run
10
+ attr_accessor :failed
11
+
12
+ # @return [Array, nil] checkers and values (alternating) to check once the render has finished
13
+ attr_accessor :rechecks
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Validation while rendering: checked steps report a failure through a fiber-local flag, and the finished output
5
+ # then goes through OutputValidator as before. Collections.items materializes collections (to_ary/to_a), so a
6
+ # render never switches fibers halfway; keep it that way.
7
+ #
8
+ # @api private
9
+ module InlineValidation
10
+ # Fiber-local key of the State (Thread#[] is fiber-local)
11
+ STATE = :versponse_inline_validation
12
+
13
+ # Marks the current run as failed; outside a run (a plain render's recovery) it does nothing
14
+ #
15
+ # @return [void]
16
+ def self.failed!
17
+ state = Thread.current[STATE]
18
+ state.failed = true if state && !state.failed.nil?
19
+ end
20
+
21
+ # @param check [#valid?] checker of the value's schema
22
+ # @param value [Object] value to check once the render has finished
23
+ # @return [void]
24
+ def self.recheck_later(check, value)
25
+ state = Thread.current[STATE]
26
+ (state.rechecks ||= []).push(check, value)
27
+ end
28
+
29
+ # @return [Boolean] true while a run is active in this fiber and one of its checks failed
30
+ def self.failed?
31
+ Thread.current[STATE]&.failed == true
32
+ end
33
+
34
+ # Checks a value a step wrote. A value that a later step of the same render could still change in place (it is
35
+ # often the source's own object) is checked once the render has finished instead: any value that isn't frozen
36
+ # all the way down, when the schema can be broken by such a change. A check that raises, or defers
37
+ # (UncaughtThrowError outside Root), fails.
38
+ #
39
+ # @param check [#valid?] checker of the value's schema
40
+ # @param value [Object] written value
41
+ # @param recheck [Boolean] whether a change in place can break the schema (see Builder)
42
+ # @return [void]
43
+ def self.check(check, value, recheck)
44
+ if recheck && !Ractor.shareable?(value)
45
+ recheck_later(check, value)
46
+ else
47
+ ok = begin
48
+ check.valid?(value)
49
+ rescue
50
+ false
51
+ end
52
+ failed! unless ok
53
+ end
54
+ end
55
+
56
+ # Runs a render with a clear flag; the previous state is restored, so a serialize inside a block is isolated
57
+ #
58
+ # @yield the render (it keeps its result itself, so nothing is allocated here)
59
+ # @return [Boolean] true when a check failed, during the render or when rechecked after it
60
+ def self.run
61
+ state = (Thread.current[STATE] ||= State.new)
62
+ previous = state.failed
63
+ previous_rechecks = state.rechecks
64
+ state.failed = false
65
+ state.rechecks = nil
66
+ yield
67
+ rechecks = state.rechecks
68
+ state.failed = true if rechecks && !rechecked?(rechecks)
69
+ state.failed
70
+ ensure
71
+ state.failed = previous
72
+ state.rechecks = previous_rechecks
73
+ end
74
+
75
+ # @param rechecks [Array] checkers and values, alternating
76
+ # @return [Boolean] true when every value passes its check
77
+ def self.rechecked?(rechecks)
78
+ rechecks.each_slice(2).all? do |check, value|
79
+ check.valid?(value)
80
+ rescue
81
+ false
82
+ end
83
+ end
84
+ private_class_method :rechecked?
85
+ end
86
+ end