verquest 0.6.3 → 0.7.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/README.md +324 -51
  4. data/Rakefile +5 -0
  5. data/docs/rails.md +169 -0
  6. data/lib/verquest/base/condition_class_methods.rb +211 -0
  7. data/lib/verquest/base/private_class_methods.rb +34 -15
  8. data/lib/verquest/base/public_class_methods.rb +51 -1
  9. data/lib/verquest/base.rb +1 -0
  10. data/lib/verquest/conditions/branch.rb +111 -0
  11. data/lib/verquest/conditions/condition.rb +194 -0
  12. data/lib/verquest/conditions/conjunction.rb +39 -0
  13. data/lib/verquest/conditions/fragment_merge.rb +52 -0
  14. data/lib/verquest/conditions/property_merge.rb +47 -0
  15. data/lib/verquest/conditions/rule_set.rb +58 -0
  16. data/lib/verquest/conditions/rules/absent.rb +25 -0
  17. data/lib/verquest/conditions/rules/alternatives.rb +28 -0
  18. data/lib/verquest/conditions/rules/any.rb +27 -0
  19. data/lib/verquest/conditions/rules/equals.rb +22 -0
  20. data/lib/verquest/conditions/rules/names_rule.rb +23 -0
  21. data/lib/verquest/conditions/rules/none.rb +24 -0
  22. data/lib/verquest/conditions/rules/present.rb +22 -0
  23. data/lib/verquest/conditions/rules/property_rule.rb +48 -0
  24. data/lib/verquest/conditions/rules/satisfies.rb +22 -0
  25. data/lib/verquest/conditions/rules/value_in.rb +22 -0
  26. data/lib/verquest/conditions/rules.rb +17 -0
  27. data/lib/verquest/conditions.rb +36 -0
  28. data/lib/verquest/configuration.rb +19 -1
  29. data/lib/verquest/gem_version.rb +1 -1
  30. data/lib/verquest/helper_methods/conditional_properties.rb +126 -0
  31. data/lib/verquest/helper_methods/unnamed_one_of.rb +35 -0
  32. data/lib/verquest/properties/array.rb +15 -1
  33. data/lib/verquest/properties/base.rb +116 -10
  34. data/lib/verquest/properties/collection.rb +51 -18
  35. data/lib/verquest/properties/const.rb +5 -4
  36. data/lib/verquest/properties/enum.rb +1 -5
  37. data/lib/verquest/properties/field.rb +1 -0
  38. data/lib/verquest/properties/object.rb +38 -4
  39. data/lib/verquest/properties/one_of/discriminated_schema.rb +84 -0
  40. data/lib/verquest/properties/one_of.rb +86 -54
  41. data/lib/verquest/properties/reference.rb +49 -37
  42. data/lib/verquest/query/coercer.rb +165 -0
  43. data/lib/verquest/query/definition_check.rb +91 -0
  44. data/lib/verquest/query/parameters.rb +79 -0
  45. data/lib/verquest/query.rb +25 -0
  46. data/lib/verquest/transformer/collection.rb +32 -0
  47. data/lib/verquest/transformer/const_as_enum.rb +47 -0
  48. data/lib/verquest/transformer/layout.rb +83 -0
  49. data/lib/verquest/transformer/mapping_layout.rb +166 -0
  50. data/lib/verquest/transformer/one_of.rb +111 -0
  51. data/lib/verquest/transformer/path.rb +188 -0
  52. data/lib/verquest/transformer/plan.rb +183 -0
  53. data/lib/verquest/transformer/scope.rb +64 -0
  54. data/lib/verquest/transformer.rb +43 -880
  55. data/lib/verquest/version.rb +152 -79
  56. data/lib/verquest.rb +6 -0
  57. metadata +43 -8
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ class Transformer
5
+ # Turns a mapping hash into a Layout
6
+ #
7
+ # A mapping is flat, holds a single oneOf (variant mappings that repeat
8
+ # the other properties, plus metadata keys), or several oneOfs (`_oneOfs`).
9
+ # The oneOf paths and collection targets the hash doesn't hold are worked
10
+ # out from its paths. Only the deprecated `Transformer.new(mapping:)` uses
11
+ # it; versions build their layout from their properties.
12
+ #
13
+ # @deprecated Removed in 1.0.0 together with `Transformer.new(mapping:)`
14
+ #
15
+ # Internal to the transformer, not part of the public API.
16
+ class MappingLayout
17
+ # Builds the layout of a mapping
18
+ #
19
+ # @param mapping [Hash] The mapping in any of its formats
20
+ # @param discriminator [String, Symbol, nil] The discriminator of a root-level oneOf
21
+ # @return [Layout] The layout
22
+ def self.call(mapping, discriminator: nil)
23
+ new(mapping, discriminator&.to_s).layout
24
+ end
25
+
26
+ private_class_method :new
27
+
28
+ # @!attribute [r] layout
29
+ # @return [Layout] The layout of the mapping
30
+ attr_reader :layout
31
+
32
+ # @param mapping [Hash] The mapping in any of its formats
33
+ # @param discriminator [String, nil] The discriminator of a root-level oneOf
34
+ def initialize(mapping, discriminator)
35
+ @layout = Layout.new
36
+ pairs, one_ofs = split(mapping, discriminator)
37
+ pairs.each { |source, target| layout.add_pair(source, target) }
38
+ one_ofs.each { |one_of| layout.add_one_of(one_of) }
39
+ one_ofs.each { |one_of| add_collections(one_of.path, one_ofs, pairs) }
40
+ end
41
+
42
+ private
43
+
44
+ # Splits a mapping into its source and target pairs and its oneOfs
45
+ #
46
+ # @param mapping [Hash] The mapping in any of its formats
47
+ # @param discriminator [String, nil] The discriminator of a root-level oneOf
48
+ # @return [Array(Hash, Array<Layout::OneOfLayout>)] The pairs and the oneOfs
49
+ def split(mapping, discriminator)
50
+ pairs = mapping.reject { |key, value| METADATA_KEYS.include?(key) || value.is_a?(Hash) }
51
+ return [pairs, mapping["_oneOfs"].map { |one_of| one_of_layout(one_of) }] if mapping.key?("_oneOfs")
52
+ return [pairs, []] unless discriminator || mapping.key?("_discriminator") || mapping.key?("_variant_schemas")
53
+
54
+ single = mapping.except(*pairs.keys)
55
+ single = single.merge("_discriminator" => discriminator) if discriminator
56
+ one_of = one_of_layout(single)
57
+ [pairs.merge(shared_pairs(single, one_of.path)), [one_of]]
58
+ end
59
+
60
+ # Builds the layout of a oneOf mapping
61
+ #
62
+ # @param mapping [Hash] The oneOf mapping, with metadata keys
63
+ # @return [Layout::OneOfLayout] The oneOf layout
64
+ def one_of_layout(mapping)
65
+ variants = variant_mappings(mapping)
66
+ path = one_of_path(mapping, variants)
67
+ discriminator = mapping["_discriminator"]
68
+
69
+ Layout::OneOfLayout.new(
70
+ path:,
71
+ discriminator:,
72
+ variants: variants.transform_values { |pairs| variant_layout(pairs, path) },
73
+ variant_schemas: (mapping["_variant_schemas"] unless discriminator),
74
+ null_target: mapping["_nullable_target_path"]
75
+ )
76
+ end
77
+
78
+ # Builds the layout of a variant from its pairs inside the oneOf
79
+ #
80
+ # @param pairs [Hash] The variant mapping
81
+ # @param path [String] The oneOf source path
82
+ # @return [Layout] The variant layout
83
+ def variant_layout(pairs, path)
84
+ layout.variant.tap do |variant|
85
+ # A Hash target is a oneOf nested in the variant, which the mapping formats can't express
86
+ pairs.each { |source, target| variant.add_pair(source, target) if target.is_a?(String) && inside?(source, path) }
87
+ end
88
+ end
89
+
90
+ # Returns the variant mappings of a oneOf mapping
91
+ #
92
+ # @param mapping [Hash] The oneOf mapping
93
+ # @return [Hash<String, Hash>] Source and target pairs by variant name
94
+ def variant_mappings(mapping)
95
+ mapping.select { |key, value| !METADATA_KEYS.include?(key) && value.is_a?(Hash) }
96
+ end
97
+
98
+ # Returns the pairs a single-oneOf mapping repeats in every variant
99
+ #
100
+ # @param mapping [Hash] The oneOf mapping
101
+ # @param path [String] The oneOf source path
102
+ # @return [Hash] The pairs outside the oneOf property
103
+ def shared_pairs(mapping, path)
104
+ return {} if path.empty?
105
+
106
+ variant_mappings(mapping).values.first.reject { |source, _| inside?(source, path) }
107
+ end
108
+
109
+ # Returns the source path of a oneOf property
110
+ #
111
+ # Taken from the discriminator, the variant path or the nullable path.
112
+ # Otherwise, the oneOf is at the root, or at the items of the collection
113
+ # all its properties are in.
114
+ #
115
+ # @param mapping [Hash] The oneOf mapping
116
+ # @param variants [Hash<String, Hash>] Source and target pairs by variant name
117
+ # @return [String] The source path, empty for a root-level oneOf
118
+ def one_of_path(mapping, variants)
119
+ return mapping["_discriminator"].rpartition("/").first if mapping["_discriminator"]
120
+ return mapping["_variant_path"] || mapping["_nullable_path"] if mapping.key?("_variant_path") || mapping.key?("_nullable_path")
121
+
122
+ sources = variants.values.flat_map(&:keys)
123
+ collection = sources.first.include?("[]") ? "#{sources.first.split("[]").first}[]" : ""
124
+ (!collection.empty? && sources.all? { |source| inside?(source, collection) }) ? collection : ""
125
+ end
126
+
127
+ # Adds the collections a oneOf path enters, with their target paths
128
+ #
129
+ # @param path [String] The oneOf source path
130
+ # @param one_ofs [Array<Layout::OneOfLayout>] All oneOfs
131
+ # @param pairs [Hash] The source and target pairs outside the oneOfs
132
+ # @return [void]
133
+ def add_collections(path, one_ofs, pairs)
134
+ 1.upto(path.scan("[]").size) do |depth|
135
+ prefix = "#{path.split("[]").first(depth).join("[]")}[]"
136
+ layout.add_collection(prefix, target_collection(prefix, one_ofs, pairs)) unless layout.collections.key?(prefix)
137
+ end
138
+ end
139
+
140
+ # Returns the target path of a collection, which differs when it uses map:
141
+ #
142
+ # Found from a property mapped inside the collection, so a property moved
143
+ # out of it with map: "/..." doesn't count.
144
+ #
145
+ # @param prefix [String] The collection source path, ending with "[]"
146
+ # @param one_ofs [Array<Layout::OneOfLayout>] All oneOfs
147
+ # @param pairs [Hash] The source and target pairs outside the oneOfs
148
+ # @return [String] The collection target path, ending with "[]"
149
+ def target_collection(prefix, one_ofs, pairs)
150
+ depth = prefix.scan("[]").size
151
+ candidates = one_ofs.flat_map { |one_of| one_of.variants.values.flat_map { |variant| variant.pairs.to_a } } + pairs.to_a
152
+ _, target = candidates.find { |source, target_path| inside?(source, prefix) && target_path.scan("[]/").size >= depth }
153
+ target ? "#{target.split("[]/").first(depth).join("[]/")}[]" : prefix
154
+ end
155
+
156
+ # Checks whether a path is inside another one
157
+ #
158
+ # @param path [String] The path
159
+ # @param prefix [String] The containing path, empty for the root
160
+ # @return [Boolean] True if the path is below the prefix
161
+ def inside?(path, prefix)
162
+ prefix.empty? || path.start_with?("#{prefix}/")
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ class Transformer
5
+ # One oneOf in a mapping, with the variant mapping each input selects
6
+ #
7
+ # Paths are relative to the scope the oneOf is resolved in: the params, or
8
+ # a collection item.
9
+ #
10
+ # @example
11
+ # one_of = OneOf.new(
12
+ # path: Path.new("pet"),
13
+ # discriminator: Path.new("pet/type"),
14
+ # variants: {"dog" => Scope.new(pairs: [[Path.new("pet/bark"), Path.new("pet/bark")]], one_ofs: [], collections: [])}
15
+ # )
16
+ # one_of.variant_for({"pet" => {"type" => "dog", "bark" => true}}) # => the "dog" scope
17
+ #
18
+ # Internal to the transformer, not part of the public API.
19
+ class OneOf
20
+ # An empty list of pairs, oneOfs or collections
21
+ NONE = [].freeze
22
+
23
+ # The variant of an absent oneOf
24
+ NO_VARIANT = Scope.new(pairs: NONE, one_ofs: NONE, collections: NONE)
25
+
26
+ # Initialize a new OneOf
27
+ #
28
+ # @param path [Path] The oneOf property, empty for a oneOf at the scope root
29
+ # @param variants [Hash<String, Scope>] The scope of each variant by name
30
+ # @param discriminator [Path, nil] The discriminator property
31
+ # @param variant_schemas [Hash<String, Hash>, nil] Validation schemas by variant name, without a discriminator
32
+ # @param null_target [Path, nil] Where a null oneOf goes, defaults to the oneOf path
33
+ def initialize(path:, variants:, discriminator: nil, variant_schemas: nil, null_target: nil)
34
+ @path = path
35
+ @variants = variants
36
+ @discriminator = discriminator
37
+ @schemers = variant_schemas&.transform_values { |schema| schemer(schema) }
38
+ @null_variant = path.empty? ? NO_VARIANT : Scope.new(pairs: [[path, null_target || path].freeze].freeze, one_ofs: NONE, collections: NONE)
39
+ freeze
40
+ end
41
+
42
+ # Returns the scope of the variant the data selects
43
+ #
44
+ # An absent oneOf maps nothing, and a null one maps only the null.
45
+ #
46
+ # @param data [Object] The scope data holding the oneOf
47
+ # @return [Scope] The variant scope
48
+ # @raise [Verquest::MappingError] If no variant or more than one variant matches
49
+ def variant_for(data)
50
+ value = path.extract(data)
51
+ return NO_VARIANT if value.equal?(Path::NOT_FOUND)
52
+ return null_variant if value.nil?
53
+
54
+ variants.fetch(discriminator ? discriminated_variant(data) : inferred_variant(value))
55
+ end
56
+
57
+ private
58
+
59
+ # @!attribute [r] path
60
+ # @return [Path] The oneOf property
61
+ # @!attribute [r] variants
62
+ # @return [Hash<String, Scope>] The scope of each variant by name
63
+ # @!attribute [r] discriminator
64
+ # @return [Path, nil] The discriminator property
65
+ # @!attribute [r] schemers
66
+ # @return [Hash<String, JSONSchemer::Schema>, nil] Schemers by variant name
67
+ # @!attribute [r] null_variant
68
+ # @return [Scope] The variant of a null oneOf
69
+ attr_reader :path, :variants, :discriminator, :schemers, :null_variant
70
+
71
+ # Compiles a variant schema in the configured JSON Schema version, like the validation schema
72
+ #
73
+ # @param schema [Hash] The variant validation schema
74
+ # @return [JSONSchemer::Schema] The schemer
75
+ def schemer(schema)
76
+ configuration = Verquest.configuration
77
+ schema = ConstAsEnum.call(schema) unless configuration.const_keyword?
78
+ JSONSchemer.schema(schema, meta_schema: configuration.json_schema_uri)
79
+ end
80
+
81
+ # @param data [Object] The scope data holding the oneOf
82
+ # @return [String] The variant named by the discriminator value
83
+ # @raise [Verquest::MappingError] If the value is missing or unknown
84
+ def discriminated_variant(data)
85
+ value = discriminator.extract(data)
86
+ raise Verquest::MappingError, "Missing discriminator \"#{discriminator}\" for oneOf." if value.nil? || value.equal?(Path::NOT_FOUND)
87
+
88
+ name = value.to_s
89
+ return name if variants.key?(name)
90
+
91
+ raise Verquest::MappingError, "Unknown discriminator value \"#{value}\" for oneOf. No matching variant found."
92
+ end
93
+
94
+ # @param value [Object] The oneOf value
95
+ # @return [String] The only variant whose schema the value matches
96
+ # @raise [Verquest::MappingError] If no variant or more than one variant matches
97
+ def inferred_variant(value)
98
+ matches = []
99
+ schemers.each do |name, schemer|
100
+ matches << name if schemer.valid?(value)
101
+ break if matches.size == 2 # Two matches are enough to know the input is ambiguous
102
+ end
103
+ return matches.first if matches.one?
104
+ raise Verquest::MappingError, "No matching schema found for oneOf. Input does not match any of the defined schemas." if matches.empty?
105
+
106
+ raise Verquest::MappingError, "Ambiguous oneOf match. Input matches multiple schemas: #{matches.join(", ")}. " \
107
+ "Consider adding a discriminator or making schemas mutually exclusive."
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ class Transformer
5
+ # A parsed slash-separated path, like "user/addresses[]/zip"
6
+ #
7
+ # A part ending with "[]" is an array: reading it reads every item, and
8
+ # writing it writes every item at its position. Paths are parsed once,
9
+ # when the transformer is built, and reading or writing allocates only
10
+ # the values and hashes it returns.
11
+ #
12
+ # @example
13
+ # path = Path.new("users[]/name")
14
+ # path.extract({"users" => [{"name" => "A"}, {}]}) # => ["A", Path::NOT_FOUND]
15
+ # path.set({}, ["A", Path::NOT_FOUND]) # => {"users" => [{"name" => "A"}, {}]}
16
+ #
17
+ # Internal to the transformer, not part of the public API.
18
+ class Path
19
+ # Returned when a key is not in the data, to tell it apart from a null value
20
+ NOT_FOUND = Object.new.freeze
21
+
22
+ # A value below an explicit null in the data
23
+ #
24
+ # Returned by #extract with `nulls: true` when a parent of the path is
25
+ # null, so the matching part of the target path can be set to null too.
26
+ #
27
+ # @!attribute [r] depth_below
28
+ # @return [Integer] How many path parts are below the null parent
29
+ NullParent = Data.define(:depth_below)
30
+
31
+ # One part of a path
32
+ #
33
+ # @!attribute [r] key
34
+ # @return [String] The hash key
35
+ # @!attribute [r] array
36
+ # @return [Boolean] Whether the key holds an array
37
+ Part = Data.define(:key, :array)
38
+
39
+ # @!attribute [r] to_s
40
+ # @return [String] The path as given
41
+ # @!attribute [r] parts
42
+ # @return [Array<Part>] The parsed parts
43
+ attr_reader :to_s, :parts
44
+
45
+ # @param path [String] The slash-separated path
46
+ def initialize(path)
47
+ @to_s = path.dup.freeze
48
+ @parts = path.split("/").map { |part| Part.new(key: part.delete_suffix("[]"), array: part.end_with?("[]")) }.freeze
49
+ # One NullParent per part, so reading a null allocates nothing
50
+ @null_parents = Array.new(parts.length) { |index| NullParent.new(depth_below: parts.length - index - 1) }.freeze
51
+ @last = parts.length - 1
52
+ freeze
53
+ end
54
+
55
+ # @return [Boolean] True for the empty path, which reads the data itself
56
+ def empty?
57
+ parts.empty?
58
+ end
59
+
60
+ # Reads the value at this path
61
+ #
62
+ # @param data [Object] The data to read from
63
+ # @param nulls [Boolean] Whether to return a NullParent for a value below an explicit null
64
+ # @return [Object, NullParent, NOT_FOUND] The value, an array of values for array parts, or NOT_FOUND
65
+ def extract(data, nulls: false)
66
+ extract_at(data, 0, nulls)
67
+ end
68
+
69
+ # Writes a value at this path
70
+ #
71
+ # Every item of an array keeps its position, even when none of its
72
+ # values are present, so empty items stay empty hashes. A NullParent
73
+ # sets the matching part of this path to null, unless it already has a value.
74
+ #
75
+ # @param result [Hash] The hash to write to
76
+ # @param value [Object, NullParent] The value, or an array of values for array parts
77
+ # @return [Hash] The result
78
+ def set(result, value)
79
+ set_at(result, value, 0)
80
+ end
81
+
82
+ private
83
+
84
+ # @!attribute [r] null_parents
85
+ # @return [Array<NullParent>] The NullParent for a null at each part
86
+ # @!attribute [r] last
87
+ # @return [Integer] The index of the last part
88
+ attr_reader :null_parents, :last
89
+
90
+ # @param data [Object] The data to read from
91
+ # @param index [Integer] The current part
92
+ # @param nulls [Boolean] Whether to return a NullParent below an explicit null
93
+ # @return [Object, NullParent, NOT_FOUND] The value
94
+ def extract_at(data, index, nulls)
95
+ return data if index > last
96
+
97
+ case data
98
+ when Hash then extract_from_hash(data, index, nulls)
99
+ # An array reached by a part without "[]" is read item by item
100
+ when Array then data.map { |item| extract_at(item, index, nulls) }
101
+ else NOT_FOUND
102
+ end
103
+ end
104
+
105
+ # @param data [Hash] The hash to read from
106
+ # @param index [Integer] The current part
107
+ # @param nulls [Boolean] Whether to return a NullParent below an explicit null
108
+ # @return [Object, NullParent, NOT_FOUND] The value
109
+ def extract_from_hash(data, index, nulls)
110
+ part = parts[index]
111
+ value = data.fetch(part.key) { return NOT_FOUND }
112
+ return null_parents[index] if value.nil? && nulls && index < last
113
+ return value.map { |item| extract_at(item, index + 1, nulls) } if part.array && value.is_a?(Array)
114
+
115
+ extract_at(value, index + 1, nulls)
116
+ end
117
+
118
+ # @param result [Hash] The hash to write to
119
+ # @param value [Object, NullParent] The value
120
+ # @param index [Integer] The current part
121
+ # @return [Hash] The result
122
+ def set_at(result, value, index)
123
+ return set_null(result, index, [last - value.depth_below, index].max) if value.is_a?(NullParent)
124
+
125
+ part = parts[index]
126
+ if index == last
127
+ result[part.key] = value.is_a?(Array) ? plain(value) : value
128
+ elsif part.array && value.is_a?(Array)
129
+ set_items(result[part.key] ||= [], value, index + 1)
130
+ else
131
+ set_at(result[part.key] ||= {}, value, index + 1)
132
+ end
133
+ result
134
+ end
135
+
136
+ # @param items [Array<Hash>] The array to write to
137
+ # @param values [Array<Object>] The values, one per item
138
+ # @param index [Integer] The part below the array
139
+ # @return [void]
140
+ def set_items(items, values, index)
141
+ values.each_with_index do |value, position|
142
+ item = (items[position] ||= {})
143
+ set_at(item, value, index) unless value.equal?(NOT_FOUND)
144
+ end
145
+ end
146
+
147
+ # Turns the markers in an array of values into nulls
148
+ #
149
+ # An array of values reaches the last part when a property is moved out
150
+ # of collection items with map: "/...". Items without the value keep
151
+ # their position as null.
152
+ #
153
+ # @param values [Array<Object>] The values, possibly nested
154
+ # @return [Array<Object>] The values with NOT_FOUND and NullParent as nil
155
+ def plain(values)
156
+ # Most arrays are array fields without markers, which are kept as they are
157
+ return values unless values.any? { |value| value.is_a?(Array) || value.is_a?(NullParent) || value.equal?(NOT_FOUND) }
158
+
159
+ values.map do |value|
160
+ case value
161
+ when Array then plain(value)
162
+ when NullParent, NOT_FOUND then nil
163
+ else value
164
+ end
165
+ end
166
+ end
167
+
168
+ # Sets the part at null_index to null, unless it already has a value
169
+ #
170
+ # The null goes as many parts above the end of this path as it is above
171
+ # the end of the source path, but never above the current part.
172
+ #
173
+ # @param result [Hash] The hash to write to
174
+ # @param index [Integer] The current part
175
+ # @param null_index [Integer] The part to set to null
176
+ # @return [Hash] The result
177
+ def set_null(result, index, null_index)
178
+ node = result
179
+ index.upto(null_index - 1) do |position|
180
+ node = (node[parts[position].key] ||= {})
181
+ return result unless node.is_a?(Hash)
182
+ end
183
+ node[parts[null_index].key] = nil unless node.key?(parts[null_index].key)
184
+ result
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ class Transformer
5
+ # Groups a Layout into the scopes the transformer applies
6
+ #
7
+ # The plan is a scope for the params, with a nested scope for the items
8
+ # of each collection that holds a oneOf, since every item can select a
9
+ # different variant. Paths in a collection scope are relative to the item.
10
+ # A oneOf variant is a scope with the same prefixes as the scope holding
11
+ # the oneOf.
12
+ #
13
+ # @example
14
+ # plan = Plan.new(layout)
15
+ # plan.root # => Scope with the params pairs, oneOfs and collections
16
+ #
17
+ # Internal to the transformer, not part of the public API.
18
+ class Plan
19
+ # The collection prefixes the paths of a scope are relative to
20
+ #
21
+ # @!attribute [r] source
22
+ # @return [String] The source collection path ending with "[]", empty for the params
23
+ # @!attribute [r] target
24
+ # @return [String] The target collection path ending with "[]", empty for the params
25
+ Prefix = Data.define(:source, :target)
26
+
27
+ # The prefix of the params scope
28
+ ROOT = Prefix.new(source: "", target: "")
29
+
30
+ private_constant :Prefix, :ROOT
31
+
32
+ # @!attribute [r] root
33
+ # @return [Scope] The mapping of the params
34
+ attr_reader :root
35
+
36
+ # Builds the plan
37
+ #
38
+ # @param layout [Layout] The full paths of the request. Each layout must list every collection a oneOf path in it enters.
39
+ # @raise [Verquest::MappingError] If map: moves a oneOf variant property out of its collection item
40
+ def initialize(layout)
41
+ @paths = {}
42
+ @root = build_scope(layout.pairs, layout.one_ofs, ROOT, layout.collections)
43
+ end
44
+
45
+ private
46
+
47
+ # Returns the target path of a collection
48
+ #
49
+ # @param collections [Hash<String, String>] Target path by source path of each collection
50
+ # @param source [String] The collection source path, ending with "[]"
51
+ # @return [String] The collection target path, ending with "[]"
52
+ # @raise [ArgumentError] If the layout doesn't list the collection
53
+ def collection_target(collections, source)
54
+ collections.fetch(source) { raise ArgumentError, "The layout doesn't list the collection #{source}" }
55
+ end
56
+
57
+ # Builds a scope, with paths relative to the prefix
58
+ #
59
+ # The oneOfs inside a collection of this scope go to a nested scope for
60
+ # that collection's items, with the pairs mapped into those items. A pair
61
+ # moved out of the items with map: "/..." stays here.
62
+ #
63
+ # @param pairs [Hash] The source and target pairs in this scope
64
+ # @param one_ofs [Array<Layout::OneOfLayout>] The oneOfs in this scope
65
+ # @param prefix [Prefix] The collection prefixes, ROOT for the params
66
+ # @param collections [Hash<String, String>] Target path by source path of the collections this scope sees
67
+ # @return [Scope] The scope
68
+ def build_scope(pairs, one_ofs, prefix, collections)
69
+ by_collection = one_ofs.group_by { |one_of| collection_of(one_of.path, prefix.source) }
70
+ item_prefixes = (by_collection.keys - [""]).map { |source| Prefix.new(source:, target: collection_target(collections, source)) }
71
+ item_pairs = item_prefixes.to_h do |item_prefix|
72
+ [item_prefix, pairs.select { |source, target| inside?(source, item_prefix.source) && inside?(target, item_prefix.target) }]
73
+ end
74
+
75
+ Scope.new(
76
+ pairs: relative_pairs(pairs.reject { |source, _| item_pairs.each_value.any? { |inner| inner.key?(source) } }, prefix),
77
+ one_ofs: by_collection.fetch("", []).map { |one_of| build_one_of(one_of, prefix) }.freeze,
78
+ collections: item_prefixes.map { |item_prefix| build_collection(item_prefix, by_collection[item_prefix.source], item_pairs[item_prefix], prefix, collections) }.freeze
79
+ )
80
+ end
81
+
82
+ # Builds a collection whose items hold a oneOf
83
+ #
84
+ # @param item_prefix [Prefix] The collection prefixes, ending with "[]"
85
+ # @param one_ofs [Array<Layout::OneOfLayout>] The oneOfs in the collection items
86
+ # @param pairs [Hash] The source and target pairs mapped into the items
87
+ # @param prefix [Prefix] The prefixes of the parent scope
88
+ # @param collections [Hash<String, String>] Target path by source path of the collections the parent scope sees
89
+ # @return [Collection] The collection
90
+ def build_collection(item_prefix, one_ofs, pairs, prefix, collections)
91
+ Collection.new(
92
+ source: path(relative(item_prefix.source.delete_suffix("[]"), prefix.source)),
93
+ target: path(relative(item_prefix.target.delete_suffix("[]"), prefix.target)),
94
+ scope: build_scope(pairs, one_ofs, item_prefix, collections)
95
+ )
96
+ end
97
+
98
+ # Builds a oneOf, with paths relative to the prefix
99
+ #
100
+ # @param one_of [Layout::OneOfLayout] The oneOf layout
101
+ # @param prefix [Prefix] The collection prefixes, ROOT for the params
102
+ # @return [OneOf] The oneOf
103
+ # @raise [Verquest::MappingError] If map: moves a variant property out of the collection item
104
+ def build_one_of(one_of, prefix)
105
+ check_variant_targets(one_of, prefix)
106
+
107
+ OneOf.new(
108
+ path: path(relative(one_of.path, prefix.source)),
109
+ variants: one_of.variants.transform_values { |variant| build_scope(variant.pairs, variant.one_ofs, prefix, variant.collections) }.freeze,
110
+ discriminator: one_of.discriminator && path(relative(one_of.discriminator, prefix.source)),
111
+ variant_schemas: one_of.variant_schemas,
112
+ null_target: one_of.null_target && path(relative(one_of.null_target, prefix.target))
113
+ )
114
+ end
115
+
116
+ # Rejects variant properties moved out of the collection item
117
+ #
118
+ # Each item selects its own variant, so its properties can only be
119
+ # mapped inside the item.
120
+ #
121
+ # @param one_of [Layout::OneOfLayout] The oneOf layout
122
+ # @param prefix [Prefix] The collection prefixes, ROOT for the params
123
+ # @return [void]
124
+ # @raise [Verquest::MappingError] If a variant property is mapped out of the item
125
+ def check_variant_targets(one_of, prefix)
126
+ one_of.variants.each_value do |variant|
127
+ source, target = variant.pairs.find { |_, target_path| !inside?(target_path, prefix.target) }
128
+ raise MappingError, "map: can't move a oneOf variant property out of its collection item (#{source} => #{target})" if source
129
+ end
130
+ end
131
+
132
+ # Returns the first collection a path enters below a scope
133
+ #
134
+ # @param path [String] The source path
135
+ # @param scope_source [String] The source prefix of the scope, empty for the params
136
+ # @return [String] The collection path ending with "[]", empty when the path enters none
137
+ def collection_of(path, scope_source)
138
+ below = relative(path, scope_source)
139
+ return "" unless below.include?("[]")
140
+
141
+ collection = "#{below.split("[]").first}[]"
142
+ scope_source.empty? ? collection : "#{scope_source}/#{collection}"
143
+ end
144
+
145
+ # Converts source and target pairs to paths relative to the prefix
146
+ #
147
+ # @param pairs [Hash] Source and target pairs
148
+ # @param prefix [Prefix] The collection prefixes, ROOT for the params
149
+ # @return [Array<Array(Path, Path)>] The relative pairs
150
+ def relative_pairs(pairs, prefix)
151
+ pairs.map { |source, target| [path(relative(source, prefix.source)), path(relative(target, prefix.target))].freeze }.freeze
152
+ end
153
+
154
+ # Checks whether a path is inside another one
155
+ #
156
+ # @param path [String] The path
157
+ # @param prefix [String] The containing path, empty for the root
158
+ # @return [Boolean] True if the path is below the prefix
159
+ def inside?(path, prefix)
160
+ prefix.empty? || path.start_with?("#{prefix}/")
161
+ end
162
+
163
+ # Removes a prefix from a path
164
+ #
165
+ # @param path [String] The path
166
+ # @param prefix [String] The prefix, empty for none
167
+ # @return [String] The path below the prefix, empty for the prefix itself
168
+ def relative(path, prefix)
169
+ return "" if path == prefix
170
+
171
+ prefix.empty? ? path : path.delete_prefix("#{prefix}/")
172
+ end
173
+
174
+ # Returns a parsed path, shared between the pairs that use it
175
+ #
176
+ # @param path [String] The slash-separated path
177
+ # @return [Path] The parsed path
178
+ def path(path)
179
+ @paths[path.to_s] ||= Path.new(path.to_s)
180
+ end
181
+ end
182
+ end
183
+ end