verquest 0.6.4 → 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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -1
  3. data/README.md +299 -45
  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 +31 -14
  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 +104 -10
  34. data/lib/verquest/properties/collection.rb +51 -18
  35. data/lib/verquest/properties/const.rb +2 -1
  36. data/lib/verquest/properties/object.rb +38 -4
  37. data/lib/verquest/properties/one_of/discriminated_schema.rb +84 -0
  38. data/lib/verquest/properties/one_of.rb +83 -44
  39. data/lib/verquest/properties/reference.rb +47 -16
  40. data/lib/verquest/query/coercer.rb +165 -0
  41. data/lib/verquest/query/definition_check.rb +91 -0
  42. data/lib/verquest/query/parameters.rb +79 -0
  43. data/lib/verquest/query.rb +25 -0
  44. data/lib/verquest/transformer/collection.rb +32 -0
  45. data/lib/verquest/transformer/const_as_enum.rb +47 -0
  46. data/lib/verquest/transformer/layout.rb +83 -0
  47. data/lib/verquest/transformer/mapping_layout.rb +166 -0
  48. data/lib/verquest/transformer/one_of.rb +111 -0
  49. data/lib/verquest/transformer/path.rb +188 -0
  50. data/lib/verquest/transformer/plan.rb +183 -0
  51. data/lib/verquest/transformer/scope.rb +64 -0
  52. data/lib/verquest/transformer.rb +43 -880
  53. data/lib/verquest/version.rb +152 -79
  54. data/lib/verquest.rb +6 -0
  55. metadata +42 -7
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Query
5
+ # Converts parsed query parameters to the types their schema expects
6
+ #
7
+ # Query strings carry only strings, so `"2"` has to become `2` and `"a,b"`
8
+ # has to become `["a", "b"]` before the params are validated. The coercer
9
+ # walks the version's validation schema next to the params. A value no rule
10
+ # converts is left unchanged, so the validation reports it.
11
+ #
12
+ # @example
13
+ # schema = {"type" => "object", "properties" => {"page" => {"type" => "integer"}}}
14
+ # Verquest::Query::Coercer.new(schema:, explodes: {}).call({"page" => "2"})
15
+ # # => {"page" => 2}
16
+ class Coercer
17
+ # Returned by a rule that doesn't apply to a value
18
+ NO_MATCH = ::Object.new.freeze
19
+
20
+ # The JSON type of each class an enum or const value can have
21
+ JSON_TYPES = {
22
+ ::Integer => "integer",
23
+ ::Float => "number",
24
+ ::String => "string",
25
+ ::TrueClass => "boolean",
26
+ ::FalseClass => "boolean",
27
+ ::NilClass => "null"
28
+ }.freeze
29
+
30
+ # Strings converted to an Integer
31
+ INTEGER_PATTERN = /\A-?\d+\z/
32
+
33
+ # Strings converted to an Integer or a Float
34
+ NUMBER_PATTERN = /\A-?\d+(\.\d+)?\z/
35
+
36
+ # Strings converted to a boolean
37
+ BOOLEANS = {"true" => true, "false" => false}.freeze
38
+
39
+ # @param schema [Hash] The version's validation schema
40
+ # @param explodes [Hash{Array<String> => Boolean}] The explode value of each array property, by path
41
+ def initialize(schema:, explodes:)
42
+ @schema = schema
43
+ @explodes = explodes
44
+ end
45
+
46
+ # Converts the params
47
+ #
48
+ # @param params [Hash] The parsed query parameters, with string or symbol keys
49
+ # @return [Hash] A copy of the params with converted values
50
+ def call(params)
51
+ coerce_properties(params, schema, [])
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :schema, :explodes
57
+
58
+ # @return [Hash] A copy of the hash with each known property converted
59
+ def coerce_properties(hash, object_schema, path)
60
+ properties = object_schema.fetch("properties", {})
61
+ additional = object_schema["additionalProperties"]
62
+
63
+ hash.each_with_object(hash.dup) do |(key, value), result|
64
+ property_schema = properties.fetch(key.to_s) { additional if additional.is_a?(Hash) }
65
+ next unless property_schema
66
+
67
+ coerced = coerce(value, property_schema, path + [key.to_s])
68
+ result[key] = coerced unless coerced.equal?(NO_MATCH)
69
+ end
70
+ end
71
+
72
+ # Tries the schema's types, then its anyOf and oneOf variants
73
+ #
74
+ # @return [Object] The converted value or NO_MATCH
75
+ def coerce(value, value_schema, path)
76
+ types(value_schema).each do |type|
77
+ coerced = coerce_type(type, value, value_schema, path)
78
+ return coerced unless coerced.equal?(NO_MATCH)
79
+ end
80
+
81
+ value_schema.values_at("anyOf", "oneOf").compact.flatten.each do |variant|
82
+ coerced = coerce(value, variant, path)
83
+ return coerced unless coerced.equal?(NO_MATCH)
84
+ end
85
+
86
+ NO_MATCH
87
+ end
88
+
89
+ # @return [Array<String>] The schema's types, inferred from enum and const values without a type
90
+ def types(value_schema)
91
+ if value_schema.key?("type")
92
+ Array(value_schema["type"])
93
+ elsif value_schema.key?("enum")
94
+ value_schema["enum"].filter_map { |value| JSON_TYPES[value.class] }.uniq
95
+ elsif value_schema.key?("const")
96
+ [JSON_TYPES[value_schema["const"].class]].compact
97
+ else
98
+ []
99
+ end
100
+ end
101
+
102
+ # @return [Object] The value converted to the type or NO_MATCH
103
+ def coerce_type(type, value, value_schema, path)
104
+ case type
105
+ when "integer" then coerce_integer(value)
106
+ when "number" then coerce_number(value)
107
+ when "boolean" then coerce_boolean(value)
108
+ when "null" then (value.nil? || value == "") ? nil : NO_MATCH
109
+ when "string" then value.is_a?(::String) ? value : NO_MATCH
110
+ when "array" then coerce_array(value, value_schema, path)
111
+ when "object" then value.is_a?(::Hash) ? coerce_properties(value, value_schema, path) : NO_MATCH
112
+ else NO_MATCH
113
+ end
114
+ end
115
+
116
+ # @return [Integer, Object] The integer or NO_MATCH
117
+ def coerce_integer(value)
118
+ return value if value.is_a?(::Integer)
119
+ return NO_MATCH unless value.is_a?(::String) && INTEGER_PATTERN.match?(value)
120
+
121
+ Integer(value, 10)
122
+ end
123
+
124
+ # @return [Numeric, Object] The number or NO_MATCH
125
+ def coerce_number(value)
126
+ return value if value.is_a?(::Numeric)
127
+ return NO_MATCH unless value.is_a?(::String) && NUMBER_PATTERN.match?(value)
128
+
129
+ value.include?(".") ? Float(value) : Integer(value, 10)
130
+ end
131
+
132
+ # @return [Boolean, Object] The boolean or NO_MATCH
133
+ def coerce_boolean(value)
134
+ return value if value == true || value == false
135
+
136
+ BOOLEANS.fetch(value, NO_MATCH)
137
+ end
138
+
139
+ # Splits a string by the array's explode value and converts each item;
140
+ # an item no rule converts is kept as it is
141
+ def coerce_array(value, array_schema, path)
142
+ items = split(value, path)
143
+ return items if items.equal?(NO_MATCH) || !array_schema.key?("items")
144
+
145
+ items.map do |item|
146
+ coerced = coerce(item, array_schema["items"], path)
147
+ coerced.equal?(NO_MATCH) ? item : coerced
148
+ end
149
+ end
150
+
151
+ # @return [Array, Object] The items of the value or NO_MATCH
152
+ def split(value, path)
153
+ return value if value.is_a?(::Array)
154
+ return NO_MATCH unless value.is_a?(::String)
155
+
156
+ explode?(path) ? [value] : value.split(",")
157
+ end
158
+
159
+ # @return [Boolean] Whether the array at the path is sent as repeated keys
160
+ def explode?(path)
161
+ explodes.fetch(path) { Verquest.configuration.query_array_explode }
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Query
5
+ # Rejects query and path versions with shapes their location can't express
6
+ #
7
+ # Query parameters are scalars, arrays of scalars, or one level of objects
8
+ # sent as `deepObject` (`filter[status]=open`). Path parameters are required
9
+ # scalars. Anything else, and oneOf properties, raise when the version is
10
+ # prepared.
11
+ class DefinitionCheck
12
+ # @param version [Verquest::Version] The query or path version to check
13
+ def initialize(version)
14
+ @version = version
15
+ end
16
+
17
+ # @return [void]
18
+ # @raise [Verquest::DefinitionError] If the version uses an unsupported shape
19
+ def call
20
+ raise DefinitionError, "Version '#{version.name}' has a root-level one_of, which #{version.location} parameters can't express" if version.combination?
21
+
22
+ version.all_properties.each_value do |property|
23
+ reject([property.name], "a one_of") if property.is_a?(Properties::OneOf)
24
+ end
25
+
26
+ version.validation_schema.fetch("properties", {}).each do |name, property_schema|
27
+ (version.location == :path) ? check_path(property_schema, name) : check(property_schema, [name], nested: false)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :version
34
+
35
+ # Rejects path parameters that aren't required scalars
36
+ #
37
+ # @return [void]
38
+ def check_path(property_schema, name)
39
+ types = variants(property_schema).flat_map { |variant| Array(variant["type"]) }
40
+
41
+ reject([name], "an array") if types.include?("array")
42
+ reject([name], "an object") if types.include?("object")
43
+ return if version.validation_schema.fetch("required", []).include?(name)
44
+
45
+ raise DefinitionError, "Property '#{name}' in version '#{version.name}' is optional, but path parameters are always required"
46
+ end
47
+
48
+ # Checks the objects and arrays of a property schema and its variants
49
+ #
50
+ # @return [void]
51
+ def check(property_schema, path, nested:)
52
+ variants(property_schema).each do |variant|
53
+ types = Array(variant["type"])
54
+
55
+ check_object(variant, path, nested:) if types.include?("object")
56
+ check_items(variant["items"], path) if types.include?("array") && variant.key?("items")
57
+ end
58
+ end
59
+
60
+ # Rejects a nested object, then checks the object's properties
61
+ #
62
+ # @return [void]
63
+ def check_object(object_schema, path, nested:)
64
+ reject(path, "a nested object") if nested
65
+
66
+ object_schema.fetch("properties", {}).each do |name, child_schema|
67
+ check(child_schema, path + [name], nested: true)
68
+ end
69
+ end
70
+
71
+ # Rejects array items that are objects or arrays
72
+ #
73
+ # @return [void]
74
+ def check_items(items_schema, path)
75
+ item_types = variants(items_schema).flat_map { |variant| Array(variant["type"]) }
76
+
77
+ reject(path, "an array of objects or arrays") if item_types.intersect?(%w[object array])
78
+ end
79
+
80
+ # @return [Array<Hash>] The schema and its anyOf and oneOf variants
81
+ def variants(schema)
82
+ [schema, *schema.values_at("anyOf", "oneOf").compact.flatten]
83
+ end
84
+
85
+ # @raise [Verquest::DefinitionError] Always
86
+ def reject(path, shape)
87
+ raise DefinitionError, "Property '#{path.join("/")}' in version '#{version.name}' is #{shape}, which #{version.location} parameters can't express"
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Query
5
+ # Builds OpenAPI parameter objects from a query or path version's validation schema
6
+ #
7
+ # Each property becomes one `in: :query` (or `in: :path`) parameter. Keys are symbols,
8
+ # including inside `schema`, because rswag reads them that way
9
+ # (`param[:in]`, `param[:style]`, `param[:schema][:type]`).
10
+ #
11
+ # @example
12
+ # schema = {"type" => "object", "required" => [], "properties" => {"page" => {"type" => "integer"}}}
13
+ # Verquest::Query::Parameters.new(schema:, options: {}).call
14
+ # # => [{name: "page", in: :query, required: false, schema: {type: "integer"}}]
15
+ class Parameters
16
+ # Schema keys moved to the parameter itself
17
+ DOCUMENTATION_KEYS = %w[description deprecated].freeze
18
+
19
+ # @param schema [Hash] The version's validation schema
20
+ # @param options [Hash{Array<String> => Hash}] The `explode:` and `examples:` of each property path
21
+ # @param location [Symbol] `:query` or `:path`, written as `in:`
22
+ def initialize(schema:, options:, location: :query)
23
+ @schema = schema
24
+ @options = options
25
+ @location = location
26
+ end
27
+
28
+ # @return [Array<Hash>] The parameter objects, in property order
29
+ def call
30
+ required = schema.fetch("required", [])
31
+
32
+ schema.fetch("properties", {}).map do |name, property_schema|
33
+ parameter(name, property_schema, required: required.include?(name))
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :schema, :options, :location
40
+
41
+ # @return [Hash] The parameter object of one property
42
+ def parameter(name, property_schema, required:)
43
+ {name:, in: location, required:}
44
+ .merge(property_schema.slice(*DOCUMENTATION_KEYS).transform_keys(&:to_sym))
45
+ .merge(serialization(name, property_schema))
46
+ .merge(examples(name))
47
+ .merge(schema: symbolize(property_schema.except(*DOCUMENTATION_KEYS)))
48
+ end
49
+
50
+ # @return [Hash] The style and explode of an array or object parameter
51
+ def serialization(name, property_schema)
52
+ types = [property_schema, *property_schema.fetch("anyOf", [])].flat_map { |variant| Array(variant["type"]) }
53
+
54
+ if types.include?("object")
55
+ {style: :deepObject, explode: true}
56
+ elsif types.include?("array")
57
+ {style: :form, explode: options.fetch([name], {}).fetch(:explode) { Verquest.configuration.query_array_explode }}
58
+ else
59
+ {}
60
+ end
61
+ end
62
+
63
+ # @return [Hash] The named Example Objects of the parameter, if it has any
64
+ def examples(name)
65
+ examples = options.dig([name], :examples)
66
+ examples ? {examples: symbolize(examples)} : {}
67
+ end
68
+
69
+ # @return [Object] The value with symbol keys in every hash
70
+ def symbolize(value)
71
+ case value
72
+ when ::Hash then value.to_h { |key, child| [key.to_sym, symbolize(child)] }
73
+ when ::Array then value.map { |child| symbolize(child) }
74
+ else value
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ # Support for query and path requests (`location :query`, `location :path`)
5
+ #
6
+ # Query strings and paths carry only strings and flat keys, so these versions
7
+ # convert their params before validating them ({Query::Coercer}), reject
8
+ # shapes their location can't express ({Query::DefinitionCheck}), and
9
+ # document their properties as OpenAPI parameters ({Query::Parameters}).
10
+ #
11
+ # @example
12
+ # class ListQuery < Verquest::Base
13
+ # location :query
14
+ #
15
+ # version "2025-06" do
16
+ # field :page, type: :integer
17
+ # array :status, type: :string, explode: false
18
+ # end
19
+ # end
20
+ #
21
+ # ListQuery.process({"page" => "2", "status" => "open,won"}, version: "2025-06")
22
+ # # => {"page" => 2, "status" => ["open", "won"]}
23
+ module Query
24
+ end
25
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ class Transformer
5
+ # A collection whose items are transformed one by one, because they hold a oneOf
6
+ #
7
+ # @!attribute [r] source
8
+ # @return [Path] The collection in the data of the parent scope
9
+ # @!attribute [r] target
10
+ # @return [Path] The collection in the result of the parent scope
11
+ # @!attribute [r] scope
12
+ # @return [Scope] The mapping of each item, with item-level paths
13
+ #
14
+ # Internal to the transformer, not part of the public API.
15
+ Collection = Data.define(:source, :target, :scope) do
16
+ # Transforms the collection items into the result
17
+ #
18
+ # A null collection, or one below a null parent, stays null.
19
+ #
20
+ # @param data [Object] The data of the parent scope
21
+ # @param result [Hash] The result of the parent scope
22
+ # @return [Hash] The result
23
+ # @raise [Verquest::MappingError] If an item's oneOf matches no variant or more than one
24
+ def call(data, result)
25
+ items = source.extract(data, nulls: true)
26
+ return result if items.equal?(Path::NOT_FOUND)
27
+
28
+ target.set(result, items.is_a?(Array) ? items.map { |item| scope.call(item) } : items)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ class Transformer
5
+ # Rewrites `const` as a one-value `enum` in a schema
6
+ #
7
+ # Draft 4 and OpenAPI 3.0 have no `const`, so variants that differ only by
8
+ # a const would all match during variant inference. A one-value `enum`
9
+ # means the same and works in every JSON Schema version.
10
+ #
11
+ # @example
12
+ # ConstAsEnum.call({"properties" => {"const" => {"const" => "a"}}})
13
+ # # => {"properties" => {"const" => {"enum" => ["a"]}}}
14
+ #
15
+ # Internal to the transformer, not part of the public API.
16
+ module ConstAsEnum
17
+ # Keywords whose value is a map from names to schemas
18
+ SCHEMA_MAPS = %w[properties patternProperties definitions $defs dependentSchemas].freeze
19
+
20
+ # Keywords whose value is data, not a schema
21
+ DATA_KEYWORDS = %w[enum default examples].freeze
22
+
23
+ module_function
24
+
25
+ # @param schema [Object] The schema, or a part of it
26
+ # @return [Object] The schema with every `const` rewritten
27
+ def call(schema)
28
+ case schema
29
+ when Hash then schema.to_h { |keyword, value| rewrite(keyword, value) }
30
+ when Array then schema.map { |item| call(item) }
31
+ else schema
32
+ end
33
+ end
34
+
35
+ # @param keyword [String] The schema keyword
36
+ # @param value [Object] Its value
37
+ # @return [Array(String, Object)] The rewritten keyword and value
38
+ def rewrite(keyword, value)
39
+ return ["enum", [value]] if keyword == "const"
40
+ return [keyword, value] if DATA_KEYWORDS.include?(keyword)
41
+ return [keyword, value.transform_values { |subschema| call(subschema) }] if SCHEMA_MAPS.include?(keyword) && value.is_a?(Hash)
42
+
43
+ [keyword, call(value)]
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ class Transformer
5
+ # The source and target paths of a request, before they are grouped into scopes
6
+ #
7
+ # Properties add themselves with full paths from the params root
8
+ # (Properties::Base#layout), and Plan groups them into the scopes the
9
+ # transformer applies. A oneOf variant gets its own layout, so it can hold
10
+ # oneOfs and collections too. Its paths are full paths as well, like the
11
+ # parent's. Each variant gets a copy of its parent's collections: Plan looks
12
+ # up a collection's target by its source path, and same-named collections
13
+ # in different variants can have different targets.
14
+ #
15
+ # A layout is filled while a version is prepared, then only read; the
16
+ # hashes and arrays its readers return are not meant to be changed.
17
+ #
18
+ # @example
19
+ # layout = Layout.new
20
+ # layout.add_pair("user/firstName", "user/first_name")
21
+ # layout.add_collection("items[]", "lines[]")
22
+ #
23
+ # Internal to the transformer, not part of the public API.
24
+ class Layout
25
+ # A oneOf with full paths
26
+ #
27
+ # The paths are Strings; Plan turns them into Paths relative to the
28
+ # scope that holds the oneOf. The variant layouts hold full paths too.
29
+ #
30
+ # @!attribute [r] path
31
+ # @return [String] The oneOf source path, empty for a root-level oneOf
32
+ # @!attribute [r] discriminator
33
+ # @return [String, nil] The discriminator source path
34
+ # @!attribute [r] variants
35
+ # @return [Hash<String, Layout>] The layout of each variant by name
36
+ # @!attribute [r] variant_schemas
37
+ # @return [Hash<String, Hash>, nil] Validation schemas by variant name, without a discriminator
38
+ # @!attribute [r] null_target
39
+ # @return [String, nil] Where a null oneOf goes, nil for its source path
40
+ OneOfLayout = Data.define(:path, :discriminator, :variants, :variant_schemas, :null_target)
41
+
42
+ # @!attribute [r] pairs
43
+ # @return [Hash<String, String>] Target path by source path
44
+ # @!attribute [r] one_ofs
45
+ # @return [Array<OneOfLayout>] The oneOfs
46
+ # @!attribute [r] collections
47
+ # @return [Hash<String, String>] Target path by source path of each collection, both ending with "[]"
48
+ attr_reader :pairs, :one_ofs, :collections
49
+
50
+ # @param collections [Hash<String, String>] The collections, shared with the parent layout
51
+ def initialize(collections: {})
52
+ @pairs = {}
53
+ @one_ofs = []
54
+ @collections = collections
55
+ end
56
+
57
+ # @param source [String] The source path
58
+ # @param target [String] The target path
59
+ # @return [void]
60
+ def add_pair(source, target)
61
+ pairs[source] = target
62
+ end
63
+
64
+ # @param one_of [OneOfLayout] The oneOf
65
+ # @return [void]
66
+ def add_one_of(one_of)
67
+ one_ofs << one_of
68
+ end
69
+
70
+ # @param source [String] The collection source path, ending with "[]"
71
+ # @param target [String] The collection target path, ending with "[]"
72
+ # @return [void]
73
+ def add_collection(source, target)
74
+ collections[source] = target
75
+ end
76
+
77
+ # @return [Layout] An empty layout for a oneOf variant, with a copy of the collections
78
+ def variant
79
+ Layout.new(collections: collections.dup)
80
+ end
81
+ end
82
+ end
83
+ end