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