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,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,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
|