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,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# Every response class (recorded by {Base.inherited}) and every declared version name
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
module Registry
|
|
8
|
+
@classes = []
|
|
9
|
+
@anonymous = ObjectSpace::WeakMap.new
|
|
10
|
+
@version_names = [].freeze
|
|
11
|
+
@range_maxes = [].freeze
|
|
12
|
+
@after_max = {}.freeze
|
|
13
|
+
@canonical = Cache.new(limit: 256)
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# @return [Array<String>] every version declared by any response class, plus every
|
|
18
|
+
# supported_versions bound, oldest first
|
|
19
|
+
attr_reader :version_names
|
|
20
|
+
|
|
21
|
+
# Replaces a registered class with the same name, so code reloading doesn't keep stale classes.
|
|
22
|
+
# Anonymous classes (Class.new, mostly in tests) are held weakly, so they can be garbage collected;
|
|
23
|
+
# one later assigned to a constant stays alive through it.
|
|
24
|
+
#
|
|
25
|
+
# @param klass [Class] response class
|
|
26
|
+
# @return [void]
|
|
27
|
+
def add(klass)
|
|
28
|
+
name = klass.name
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
next @anonymous[klass] = true unless name
|
|
31
|
+
|
|
32
|
+
@classes.reject! { |registered| registered.name == name }
|
|
33
|
+
@classes << klass
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param name [String] declared version name
|
|
38
|
+
# @return [void]
|
|
39
|
+
def add_version_name(name)
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
@version_names = (@version_names | [name]).sort.freeze
|
|
42
|
+
@after_max = {}.freeze
|
|
43
|
+
@canonical = Cache.new(limit: 256)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Registers a supported_versions range, so canonical names never cross its bounds
|
|
48
|
+
#
|
|
49
|
+
# @param range [Versponse::SupportedVersions] declared range
|
|
50
|
+
# @return [void]
|
|
51
|
+
def add_range(range)
|
|
52
|
+
[range.min, range.max].compact.each { |name| add_version_name(name) }
|
|
53
|
+
return unless range.max
|
|
54
|
+
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
@range_maxes = (@range_maxes | [range.max]).freeze
|
|
57
|
+
@canonical = Cache.new(limit: 256)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Maps a requested version to the closest earlier registered name, so arbitrary client-sent
|
|
62
|
+
# strings can't grow the caches. Every declared version and supported_versions bound is
|
|
63
|
+
# registered, so two requests with the same canonical name resolve alike in every class.
|
|
64
|
+
# A request strictly after a max maps to the first such request seen instead of the max,
|
|
65
|
+
# so it stays outside that range.
|
|
66
|
+
#
|
|
67
|
+
# @param name [String] requested version
|
|
68
|
+
# @return [String] canonical version name, or name itself when it precedes every registered one
|
|
69
|
+
def canonical(name)
|
|
70
|
+
@canonical[name] || memoized_canonical(name)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param name [String] requested version
|
|
74
|
+
# @return [String] canonical name, stored when it's a registered name or a representative (a request before
|
|
75
|
+
# every registered name maps to itself and isn't stored, so such strings can't fill the cache)
|
|
76
|
+
def memoized_canonical(name)
|
|
77
|
+
result = resolve_canonical(name)
|
|
78
|
+
(result.equal?(name) && !@version_names.include?(name)) ? result : @canonical.fetch(name) { result }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @param name [String] requested version
|
|
82
|
+
# @return [String] closest registered name, or a representative after a max, or name itself
|
|
83
|
+
def resolve_canonical(name)
|
|
84
|
+
closest = Versponse.configuration.version_resolver.call(name, @version_names)
|
|
85
|
+
return name if closest.nil?
|
|
86
|
+
return closest if closest == name || !@range_maxes.include?(closest)
|
|
87
|
+
|
|
88
|
+
@after_max[closest] || @mutex.synchronize do
|
|
89
|
+
@after_max[closest] || (@after_max = @after_max.merge(closest => name).freeze)[closest]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private :memoized_canonical, :resolve_canonical
|
|
94
|
+
|
|
95
|
+
# Forgets the representatives of requests after a max, which depend on the version resolver
|
|
96
|
+
#
|
|
97
|
+
# @return [void]
|
|
98
|
+
def clear_canonical
|
|
99
|
+
@mutex.synchronize do
|
|
100
|
+
@after_max = {}.freeze
|
|
101
|
+
@canonical = Cache.new(limit: 256)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [Array<Class>] every registered class, including anonymous ones
|
|
106
|
+
def all
|
|
107
|
+
@mutex.synchronize { @classes + @anonymous.keys }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @return [Array<Class>] named classes still bound to their constant (skips stale reloaded classes)
|
|
111
|
+
def classes
|
|
112
|
+
all.select { |klass| live?(klass) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def live?(klass)
|
|
118
|
+
klass.name && ::Object.const_defined?(klass.name) && ::Object.const_get(klass.name).equal?(klass)
|
|
119
|
+
rescue NameError
|
|
120
|
+
false
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# State of one schema generation. In validation mode referenced classes are inlined.
|
|
5
|
+
# A class that recurses into itself gets "$anchor": "<Name>" (or the configured JSON Schema version's
|
|
6
|
+
# equivalent, see Configuration#anchor) and is referenced as "#<Name>".
|
|
7
|
+
# Anchors (unlike "#/$defs/..." pointers) still resolve when the schema is embedded in
|
|
8
|
+
# another document, for example by verquest. Anchor names are unique within one generated
|
|
9
|
+
# schema only; keep component names unique across classes when several are embedded together.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
class SchemaContext
|
|
13
|
+
# @return [String] requested version name
|
|
14
|
+
attr_reader :version
|
|
15
|
+
|
|
16
|
+
# @param version [String] requested version name
|
|
17
|
+
# @param validation [Boolean] true to inline references
|
|
18
|
+
def initialize(version:, validation:)
|
|
19
|
+
@version = version
|
|
20
|
+
@validation = validation
|
|
21
|
+
@stack = []
|
|
22
|
+
@recursive = {}
|
|
23
|
+
@anchored = {}
|
|
24
|
+
@anchors = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Boolean] true when references are inlined
|
|
28
|
+
def validation?
|
|
29
|
+
@validation
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param identity [Object] what is being inlined, e.g. [klass, view]; two classes can share a component name
|
|
33
|
+
# @param name [String] component name, used for the anchor
|
|
34
|
+
# @yieldreturn [Hash] the component schema
|
|
35
|
+
# @return [Hash] the schema, or an anchor reference when the identity is recursive
|
|
36
|
+
def inline(identity, name)
|
|
37
|
+
return anchor_ref(identity, name) if @anchored[identity]
|
|
38
|
+
|
|
39
|
+
if @stack.include?(identity)
|
|
40
|
+
@recursive[identity] = true
|
|
41
|
+
return anchor_ref(identity, name)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@stack.push(identity)
|
|
45
|
+
schema = yield
|
|
46
|
+
@stack.pop
|
|
47
|
+
return schema unless @recursive[identity]
|
|
48
|
+
|
|
49
|
+
@anchored[identity] = true
|
|
50
|
+
schema.merge(Versponse.configuration.anchor(anchor_name(identity, name)))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def anchor_ref(identity, name)
|
|
56
|
+
{"$ref" => "##{anchor_name(identity, name)}"}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def anchor_name(identity, name)
|
|
60
|
+
@anchors[identity] ||= begin
|
|
61
|
+
base = name.to_s.empty? ? "Anonymous" : name
|
|
62
|
+
candidate = base
|
|
63
|
+
suffix = 1
|
|
64
|
+
candidate = "#{base}#{suffix += 1}" while @anchors.value?(candidate)
|
|
65
|
+
candidate
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# Merges a property's schema keywords into the schema Versponse generated for it without losing the generated
|
|
5
|
+
# combinators (a type union's anyOf, a nullable or wrapped $ref's anyOf/allOf), as verquest does: allOf lists
|
|
6
|
+
# are joined, and an anyOf or oneOf on both sides go into allOf side by side.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
module SchemaMerge
|
|
10
|
+
# Combinators that can't be merged key by key
|
|
11
|
+
EXCLUSIVE = %w[anyOf oneOf].freeze
|
|
12
|
+
|
|
13
|
+
# @param schema [Hash] generated schema
|
|
14
|
+
# @param options [Hash{String => Object}] the property's camelized schema keywords
|
|
15
|
+
# @return [Hash] the merged schema
|
|
16
|
+
def self.call(schema, options)
|
|
17
|
+
clashes = EXCLUSIVE.select { |keyword| schema.key?(keyword) && options.key?(keyword) }
|
|
18
|
+
return schema.merge(options) if clashes.empty? && !(schema.key?("allOf") && options.key?("allOf"))
|
|
19
|
+
|
|
20
|
+
all_of = [*options["allOf"], *schema["allOf"]] + clashes.flat_map { |keyword| [{keyword => schema[keyword]}, {keyword => options[keyword]}] }
|
|
21
|
+
schema.except(*clashes).merge(options.except(*clashes)).merge("allOf" => all_of)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
class Selection
|
|
5
|
+
# A selected render's expand tree node paired with its selection node. Plans and steps take it where they take an
|
|
6
|
+
# Expansion, so renders without fields: pass the plain Expansion and run as before.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class Scope
|
|
10
|
+
# @return [Versponse::Selection] selected fields of this object
|
|
11
|
+
attr_reader :selection
|
|
12
|
+
|
|
13
|
+
# @param expansion [Versponse::Expansion] expand tree node
|
|
14
|
+
# @param selection [Versponse::Selection] selection node (not ALL)
|
|
15
|
+
def initialize(expansion, selection)
|
|
16
|
+
@expansion = expansion
|
|
17
|
+
@selection = selection
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param key [String] output key
|
|
22
|
+
# @return [Boolean] true when the association under key is expanded
|
|
23
|
+
def expand?(key)
|
|
24
|
+
@expansion.expand?(key)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param key [String] output key
|
|
28
|
+
# @return [Scope, Versponse::Expansion] the child's scope, or its plain expand tree node when the key is
|
|
29
|
+
# selected whole
|
|
30
|
+
def child(key)
|
|
31
|
+
selection = @selection.child(key)
|
|
32
|
+
selection.all? ? @expansion.child(key) : Scope.new(@expansion.child(key), selection)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# Tree of the fields a render selects (serialize fields:), built from dotted paths. A node selects some keys of
|
|
5
|
+
# one object; a key selected whole (or with ".*") has the ALL node. Each node memoizes the step indexes of each
|
|
6
|
+
# plan it meets; trees are cached per class, so the memo is thread-safe.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class Selection
|
|
10
|
+
# @param paths [Array<String, Symbol>, nil] validated paths
|
|
11
|
+
# @return [Selection] ALL for none, or with a root "*"
|
|
12
|
+
def self.build(paths)
|
|
13
|
+
paths = Array(paths).map(&:to_s)
|
|
14
|
+
return ALL if paths.empty? || paths.include?("*")
|
|
15
|
+
|
|
16
|
+
paths = paths.map { |path| path.delete_suffix(".*") }.uniq.sort
|
|
17
|
+
tree = {}
|
|
18
|
+
paths.each { |path| add(tree, path.split(".")) }
|
|
19
|
+
from_hash(tree, signature: paths.join("\n").freeze)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Selection] a selection of no keys: plans render only what always renders (identifiers and consts)
|
|
23
|
+
def self.none
|
|
24
|
+
new({}.freeze, signature: "")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param tree [Hash{String => Hash, Symbol}] node being built (:all for a key selected whole)
|
|
28
|
+
# @param segments [Array<String>] path segments
|
|
29
|
+
# @return [void]
|
|
30
|
+
def self.add(tree, segments)
|
|
31
|
+
*parents, leaf = segments
|
|
32
|
+
node = parents.reduce(tree) do |current, segment|
|
|
33
|
+
child = current[segment]
|
|
34
|
+
break if child == :all
|
|
35
|
+
|
|
36
|
+
child || (current[segment] = {})
|
|
37
|
+
end
|
|
38
|
+
node[leaf] = :all if node
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param tree [Hash{String => Hash, Symbol}] built tree
|
|
42
|
+
# @param signature [String, nil] the root's sorted paths
|
|
43
|
+
# @return [Selection] node
|
|
44
|
+
def self.from_hash(tree, signature: nil)
|
|
45
|
+
new(tree.transform_values { |child| (child == :all) ? ALL : from_hash(child) }.freeze, signature:)
|
|
46
|
+
end
|
|
47
|
+
private_class_method :add, :from_hash
|
|
48
|
+
|
|
49
|
+
# @return [String, nil] sorted paths of a root selection, for cache keys
|
|
50
|
+
attr_reader :signature
|
|
51
|
+
|
|
52
|
+
# @param children [Hash{String => Selection}, nil] selected keys and their nodes; nil selects everything
|
|
53
|
+
# @param signature [String, nil] sorted paths of a root selection
|
|
54
|
+
def initialize(children = nil, signature: nil)
|
|
55
|
+
@children = children
|
|
56
|
+
@signature = signature
|
|
57
|
+
@indexes = Cache.new unless children.nil?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Boolean] true when every key is selected
|
|
61
|
+
def all?
|
|
62
|
+
@children.nil?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param key [String] output key
|
|
66
|
+
# @return [Boolean] true when the key is selected
|
|
67
|
+
def selected?(key)
|
|
68
|
+
all? || @children.key?(key)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @param key [String] output key
|
|
72
|
+
# @return [Selection] the key's node: ALL for a key selected whole, under ALL, or not selected (a plan only asks
|
|
73
|
+
# for keys it renders)
|
|
74
|
+
def child(key)
|
|
75
|
+
all? ? ALL : @children.fetch(key, ALL)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @param plan [#steps, #always] plan rendered at this node
|
|
79
|
+
# @return [Array<Integer>] frozen indexes of the plan's selected and always-emitted steps, memoized per plan
|
|
80
|
+
def indexes_for(plan)
|
|
81
|
+
@indexes[plan] || @indexes.fetch(plan) do
|
|
82
|
+
plan.steps.each_index.select do |index|
|
|
83
|
+
key = plan.steps[index].key
|
|
84
|
+
@children.key?(key) || plan.always.include?(key)
|
|
85
|
+
end.freeze
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Everything selected: today's output
|
|
90
|
+
ALL = new.freeze
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
# A validation schema for a selected render: walking the schema with the selection, each object schema at a
|
|
5
|
+
# selected node requires only the selected keys. Properties stay, so emitted values are still checked.
|
|
6
|
+
#
|
|
7
|
+
# Recursive classes are inlined once with an anchor and referenced below it. A reference met under a selection is
|
|
8
|
+
# replaced by the pruned anchored schema. Pruned copies drop the anchor, and the untouched anchored originals go
|
|
9
|
+
# under the definitions keyword, so references at fully selected nodes still resolve to the full shape.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
module SelectionSchema
|
|
13
|
+
# Prefix of the definitions holding the original anchored schemas
|
|
14
|
+
DEFINITION_PREFIX = "versponse_"
|
|
15
|
+
|
|
16
|
+
# @param schema [Hash] validation schema
|
|
17
|
+
# @param selection [Versponse::Selection] the render's selection
|
|
18
|
+
# @return [Hash] the pruned copy, or the schema itself for ALL
|
|
19
|
+
def self.call(schema, selection)
|
|
20
|
+
return schema if selection.all? || !schema.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
anchors = {}
|
|
23
|
+
collect_anchors(schema, anchors)
|
|
24
|
+
pruned = prune(schema, selection, anchors)
|
|
25
|
+
return pruned if anchors.empty?
|
|
26
|
+
|
|
27
|
+
keyword = definitions_keyword
|
|
28
|
+
pruned.merge(keyword => (pruned[keyword] || {}).merge(anchors.transform_keys { |name| "#{DEFINITION_PREFIX}#{name}" }))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param schema [Object] (sub)schema
|
|
32
|
+
# @param selection [Versponse::Selection] node for the instance this schema describes
|
|
33
|
+
# @param anchors [Hash{String => Hash}] anchored schemas by name
|
|
34
|
+
# @return [Object] the pruned copy
|
|
35
|
+
def self.prune(schema, selection, anchors)
|
|
36
|
+
return schema if selection.all? || !schema.is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
target = anchors[ref_name(schema)]
|
|
39
|
+
return prune(target, selection, anchors) if target
|
|
40
|
+
|
|
41
|
+
pruned = schema.except(anchor_keyword)
|
|
42
|
+
%w[anyOf allOf].each { |keyword| pruned[keyword] = schema[keyword].map { |branch| prune(branch, selection, anchors) } if schema[keyword].is_a?(::Array) }
|
|
43
|
+
if schema["oneOf"].is_a?(::Array)
|
|
44
|
+
# Pruned variants can overlap; the render already picked one with match:, so any of them passing is enough
|
|
45
|
+
pruned.delete("oneOf")
|
|
46
|
+
pruned["allOf"] = [*pruned["allOf"], {"anyOf" => schema["oneOf"].map { |branch| prune(branch, selection, anchors) }}]
|
|
47
|
+
end
|
|
48
|
+
pruned["items"] = prune(schema["items"], selection, anchors) if schema["items"].is_a?(Hash)
|
|
49
|
+
return pruned unless schema["properties"].is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
pruned["properties"] = schema["properties"].to_h do |key, property|
|
|
52
|
+
[key, selection.selected?(key) ? prune(property, selection.child(key), anchors) : property]
|
|
53
|
+
end
|
|
54
|
+
required = schema["required"].select { |key| selection.selected?(key) } if schema["required"].is_a?(::Array)
|
|
55
|
+
# OpenAPI 3.0 and draft 4 need at least one item in required
|
|
56
|
+
pruned.delete("required")
|
|
57
|
+
pruned["required"] = required if required&.any?
|
|
58
|
+
pruned
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param schema [Object] (sub)schema
|
|
62
|
+
# @param anchors [Hash{String => Hash}] filled with anchored schemas by name
|
|
63
|
+
# @return [void]
|
|
64
|
+
def self.collect_anchors(schema, anchors)
|
|
65
|
+
case schema
|
|
66
|
+
when Hash
|
|
67
|
+
name = anchor_name(schema)
|
|
68
|
+
anchors[name] = schema if name
|
|
69
|
+
schema.each_value { |value| collect_anchors(value, anchors) }
|
|
70
|
+
when ::Array then schema.each { |value| collect_anchors(value, anchors) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [String] "$anchor", or "$id"/"id" for JSON Schema versions before 2019-09
|
|
75
|
+
def self.anchor_keyword
|
|
76
|
+
Versponse.configuration.anchor("x").keys.first
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [String] "$defs", or "definitions" for JSON Schema versions before 2019-09
|
|
80
|
+
def self.definitions_keyword
|
|
81
|
+
(anchor_keyword == "$anchor") ? "$defs" : "definitions"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @param schema [Hash] (sub)schema
|
|
85
|
+
# @return [String, nil] the name it is anchored under
|
|
86
|
+
def self.anchor_name(schema)
|
|
87
|
+
value = schema[anchor_keyword]
|
|
88
|
+
return unless value.is_a?(String)
|
|
89
|
+
|
|
90
|
+
(anchor_keyword == "$anchor") ? value : value.delete_prefix("#")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @param schema [Hash] (sub)schema
|
|
94
|
+
# @return [String, nil] the anchor a "#Name" $ref points at
|
|
95
|
+
def self.ref_name(schema)
|
|
96
|
+
ref = schema["$ref"]
|
|
97
|
+
ref.delete_prefix("#") if ref.is_a?(String) && ref.start_with?("#") && !ref.start_with?("#/")
|
|
98
|
+
end
|
|
99
|
+
private_class_method :prune, :collect_anchors, :anchor_keyword, :definitions_keyword, :anchor_name, :ref_name
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Steps
|
|
5
|
+
# Any step whose written value is checked afterwards (nested values, one_of); checks only when the step wrote
|
|
6
|
+
# its key, which is sound because validating plans have unique keys
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class Checked
|
|
10
|
+
# @return [String] output key
|
|
11
|
+
attr_reader :key
|
|
12
|
+
|
|
13
|
+
# @param step [#call] wrapped step
|
|
14
|
+
# @param check [#valid?] checker of the written value
|
|
15
|
+
def initialize(step, check)
|
|
16
|
+
@step = step
|
|
17
|
+
@key = step.key
|
|
18
|
+
@check = check
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Boolean] true when the wrapped step has if:/unless:
|
|
23
|
+
def conditional?
|
|
24
|
+
@step.conditional?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param object [Object] source
|
|
28
|
+
# @param hash [Boolean] whether the source is a Hash
|
|
29
|
+
# @param context [Hash] serialize context
|
|
30
|
+
# @param output [Hash] Hash being built
|
|
31
|
+
# @param expansion [Versponse::Expansion] expand tree node of the parent
|
|
32
|
+
# @return [void]
|
|
33
|
+
def call(object, hash, context, output, expansion)
|
|
34
|
+
@step.call(object, hash, context, output, expansion)
|
|
35
|
+
return unless output.key?(@key)
|
|
36
|
+
|
|
37
|
+
# The written subtree holds the source's values (a one_of can even write a source String), so it is checked
|
|
38
|
+
# once the render has finished
|
|
39
|
+
InlineValidation.check(@check, output[@key], true)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Steps
|
|
5
|
+
# A plain read that checks the value it writes
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class CheckedRead < Read
|
|
9
|
+
# @param key [String] output key
|
|
10
|
+
# @param name [String, Symbol] method or Hash key to read
|
|
11
|
+
# @param formatter [#call, nil] formatter (used by CheckedReadFormatted)
|
|
12
|
+
# @param check [#valid?] checker of the property's schema
|
|
13
|
+
# @param recheck [Boolean] a change in place can break the schema, so a value not frozen all the way down is
|
|
14
|
+
# checked after the render (see InlineValidation.check)
|
|
15
|
+
def initialize(key:, name:, formatter:, check:, recheck:)
|
|
16
|
+
@check = check
|
|
17
|
+
@recheck = recheck
|
|
18
|
+
super(key:, name:, formatter:)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The check is written out here, in CheckedReadFormatted and in CheckedValue, rather than calling
|
|
22
|
+
# InlineValidation.check: the extra call costs about 25 ns per field under YJIT
|
|
23
|
+
#
|
|
24
|
+
# @param object [Object] source
|
|
25
|
+
# @param hash [Boolean] whether the source is a Hash
|
|
26
|
+
# @param _context [Hash] unused
|
|
27
|
+
# @param output [Hash] Hash being built
|
|
28
|
+
# @param _expansion [Versponse::Expansion] unused
|
|
29
|
+
# @return [void]
|
|
30
|
+
def call(object, hash, _context, output, _expansion)
|
|
31
|
+
value = output[@key] = hash ? object.fetch(@symbol) { object.fetch(@string) } : object.public_send(@symbol)
|
|
32
|
+
if @recheck && !Ractor.shareable?(value)
|
|
33
|
+
InlineValidation.recheck_later(@check, value)
|
|
34
|
+
else
|
|
35
|
+
# The rescue covers the check only: a check that raises, or defers (UncaughtThrowError outside Root), fails
|
|
36
|
+
ok = begin
|
|
37
|
+
@check.valid?(value)
|
|
38
|
+
rescue
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
InlineValidation.failed! unless ok
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Steps
|
|
5
|
+
# A formatted read that checks the formatted value it writes
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class CheckedReadFormatted < CheckedRead
|
|
9
|
+
# @param object [Object] source
|
|
10
|
+
# @param hash [Boolean] whether the source is a Hash
|
|
11
|
+
# @param _context [Hash] unused
|
|
12
|
+
# @param output [Hash] Hash being built
|
|
13
|
+
# @param _expansion [Versponse::Expansion] unused
|
|
14
|
+
# @return [void]
|
|
15
|
+
def call(object, hash, _context, output, _expansion)
|
|
16
|
+
value = hash ? object.fetch(@symbol) { object.fetch(@string) } : object.public_send(@symbol)
|
|
17
|
+
value = output[@key] = value.nil? ? nil : @formatter.call(value)
|
|
18
|
+
if @recheck && !Ractor.shareable?(value)
|
|
19
|
+
InlineValidation.recheck_later(@check, value)
|
|
20
|
+
else
|
|
21
|
+
# The rescue covers the check only: a check that raises, or defers (UncaughtThrowError outside Root), fails
|
|
22
|
+
ok = begin
|
|
23
|
+
@check.valid?(value)
|
|
24
|
+
rescue
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
InlineValidation.failed! unless ok
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Steps
|
|
5
|
+
# A value step that checks the value it writes
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class CheckedValue < Value
|
|
9
|
+
# @param key [String] output key
|
|
10
|
+
# @param extractor [#call] value reader
|
|
11
|
+
# @param condition [Versponse::Condition, nil] if:/unless:
|
|
12
|
+
# @param formatter [#call, nil] applied to non-nil values
|
|
13
|
+
# @param check [#valid?] checker of the property's schema
|
|
14
|
+
# @param recheck [Boolean] a change in place can break the schema, so a value not frozen all the way down is
|
|
15
|
+
# checked after the render (see InlineValidation.check)
|
|
16
|
+
def initialize(key:, extractor:, condition:, formatter:, check:, recheck:)
|
|
17
|
+
@check = check
|
|
18
|
+
@recheck = recheck
|
|
19
|
+
super(key:, extractor:, condition:, formatter:)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param object [Object] source
|
|
23
|
+
# @param hash [Boolean] whether the source is a Hash
|
|
24
|
+
# @param context [Hash] serialize context
|
|
25
|
+
# @param output [Hash] Hash being built
|
|
26
|
+
# @param _expansion [Versponse::Expansion] unused
|
|
27
|
+
# @return [void]
|
|
28
|
+
def call(object, hash, context, output, _expansion)
|
|
29
|
+
return if @condition && !@condition.call(object, context)
|
|
30
|
+
|
|
31
|
+
value = @extractor.call(object, hash, context)
|
|
32
|
+
value = @formatter.call(value) if @formatter && !value.nil?
|
|
33
|
+
output[@key] = value
|
|
34
|
+
if @recheck && !Ractor.shareable?(value)
|
|
35
|
+
InlineValidation.recheck_later(@check, value)
|
|
36
|
+
else
|
|
37
|
+
# The rescue covers the check only: a check that raises, or defers (UncaughtThrowError outside Root), fails
|
|
38
|
+
ok = begin
|
|
39
|
+
@check.valid?(value)
|
|
40
|
+
rescue
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
InlineValidation.failed! unless ok
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Versponse
|
|
4
|
+
module Steps
|
|
5
|
+
# Writes a constant value
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class Const
|
|
9
|
+
# @return [String] output key
|
|
10
|
+
attr_reader :key
|
|
11
|
+
# @return [Object] the frozen constant value (each render copies a Hash or Array)
|
|
12
|
+
attr_reader :value
|
|
13
|
+
|
|
14
|
+
# @param key [String] output key
|
|
15
|
+
# @param value [Object] constant value
|
|
16
|
+
# @param condition [Versponse::Condition, nil] if:/unless:
|
|
17
|
+
def initialize(key:, value:, condition:)
|
|
18
|
+
@key = key
|
|
19
|
+
@value = value
|
|
20
|
+
# A Hash or Array is the caller's to change, so each render gets its own copy
|
|
21
|
+
@copy = DeepCopy.container?(value)
|
|
22
|
+
@condition = condition
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Boolean] true with if:/unless:
|
|
27
|
+
def conditional?
|
|
28
|
+
!@condition.nil?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param object [Object] source
|
|
32
|
+
# @param _hash [Boolean] unused
|
|
33
|
+
# @param context [Hash] serialize context
|
|
34
|
+
# @param output [Hash] Hash being built
|
|
35
|
+
# @param _expansion [Versponse::Expansion] unused
|
|
36
|
+
# @return [void]
|
|
37
|
+
def call(object, _hash, context, output, _expansion)
|
|
38
|
+
return if @condition && !@condition.call(object, context)
|
|
39
|
+
|
|
40
|
+
output[@key] = @copy ? DeepCopy.call(@value) : @value
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|