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,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Conditions
5
+ module Rules
6
+ # Base class for rules that constrain the value of a single property
7
+ #
8
+ # Inside `given`, the rule also requires the property. Without that,
9
+ # `if: {properties: {kind: {const: "x"}}}` would match when `kind` is missing.
10
+ #
11
+ # @abstract Subclass and pass the property schema to {#initialize}
12
+ class PropertyRule
13
+ # Initialize a new property rule
14
+ #
15
+ # @param name [Symbol, String] The property the rule constrains
16
+ # @param schema [Hash] The schema the property value must match
17
+ def initialize(name, schema)
18
+ @name = name.to_s
19
+ @schema = schema
20
+ end
21
+
22
+ # Returns the names of the properties the rule uses
23
+ #
24
+ # @return [Array<String>] The constrained property name
25
+ def property_names
26
+ [name]
27
+ end
28
+
29
+ # Builds the schema fragment for this rule
30
+ #
31
+ # @param in_given [Boolean] Whether the rule is used inside `given`, which also requires the property
32
+ # @return [Hash] The schema fragment
33
+ def to_schema(in_given: false)
34
+ fragment = {"properties" => {name => schema}}
35
+ in_given ? {"required" => [name]}.merge(fragment) : fragment
36
+ end
37
+
38
+ private
39
+
40
+ # @!attribute [r] name
41
+ # @return [String] The constrained property name
42
+ # @!attribute [r] schema
43
+ # @return [Hash] The schema the property value must match
44
+ attr_reader :name, :schema
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Conditions
5
+ module Rules
6
+ # Requires a property to match arbitrary schema options
7
+ #
8
+ # @example
9
+ # Satisfies.new(:age, {minimum: 18}).to_schema(in_given: false)
10
+ # # => {"properties" => {"age" => {"minimum" => 18}}}
11
+ class Satisfies < PropertyRule
12
+ # Initialize a new Satisfies rule
13
+ #
14
+ # @param name [Symbol, String] The property the rule constrains
15
+ # @param schema_options [Hash] Schema options (already camelized) the property value must match
16
+ def initialize(name, schema_options)
17
+ super(name, schema_options.transform_keys(&:to_s))
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Conditions
5
+ module Rules
6
+ # Requires a property to hold one of several values
7
+ #
8
+ # @example
9
+ # ValueIn.new(:kind, %w[a b]).to_schema(in_given: false)
10
+ # # => {"properties" => {"kind" => {"enum" => ["a", "b"]}}}
11
+ class ValueIn < PropertyRule
12
+ # Initialize a new ValueIn rule
13
+ #
14
+ # @param name [Symbol, String] The property the rule constrains
15
+ # @param values [Array] The values the property may hold
16
+ def initialize(name, values)
17
+ super(name, {"enum" => values})
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Conditions
5
+ # Rules used inside `given`, `then_apply`, `else_apply` and `depends_on`
6
+ #
7
+ # Each rule names properties of the scope its condition belongs to and
8
+ # builds a JSON Schema fragment. Every rule responds to `#property_names`
9
+ # (used to catch unknown names) and `#to_schema(in_given:)`.
10
+ #
11
+ # @see Verquest::Conditions::RuleSet Combines the rules of one block
12
+ module Rules
13
+ # This module is a namespace for the rule classes
14
+ # Each rule is defined in its own file under lib/verquest/conditions/rules/
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ # Conditional schemas (`if` / `then` / `else`)
5
+ #
6
+ # A condition tests which keys a payload has and what values they hold, and
7
+ # applies one of two branches. Branches can declare properties and rules.
8
+ # Branch properties are listed at the scope level, next to the regular
9
+ # properties, and the branches only get rules (`required`, `name: false`, ...).
10
+ #
11
+ # @example Price adjustment with two variants
12
+ # class PriceAdjustmentRequest < Verquest::Base
13
+ # version "2025-09" do
14
+ # field :effective_from, type: :string, required: true
15
+ #
16
+ # condition :pricing do
17
+ # given { present :percentages }
18
+ # then_apply do
19
+ # object :percentages, required: true do
20
+ # field :unit_types, type: :number
21
+ # end
22
+ # end
23
+ # else_apply do
24
+ # field :percentage, type: :number, required: true
25
+ # end
26
+ # end
27
+ # end
28
+ # end
29
+ #
30
+ # @see Verquest::Conditions::Condition
31
+ # @see Verquest::HelperMethods::ConditionalProperties
32
+ module Conditions
33
+ # This module is a namespace for the condition classes
34
+ # Each class is defined in its own file under lib/verquest/conditions/
35
+ end
36
+ end
@@ -15,6 +15,9 @@ module Verquest
15
15
  class Configuration
16
16
  include Base::HelperClassMethods
17
17
 
18
+ # JSON Schema versions without the `const` keyword
19
+ VERSIONS_WITHOUT_CONST = %i[draft4 openapi30].freeze
20
+
18
21
  # Mapping of supported JSON Schema versions to their implementation classes
19
22
  #
20
23
  # This constant maps the symbolic names of JSON Schema versions to their
@@ -58,8 +61,13 @@ module Verquest
58
61
  # @!attribute [rw] default_additional_properties
59
62
  # Controls the default behavior for handling properties not defined in the schema
60
63
  # @return [Boolean] false to disallow additional properties (default), true to allow them
64
+ #
65
+ # @!attribute [rw] query_array_explode
66
+ # Default explode value of array properties in query requests (`location :query`)
67
+ # @return [Boolean] true for repeated keys like `status[]=a&status[]=b` (default), false for comma-separated values like `status=a,b`
61
68
  attr_accessor :validate_params, :json_schema_version, :validation_error_handling,
62
- :remove_extra_root_keys, :insert_property_defaults, :default_additional_properties
69
+ :remove_extra_root_keys, :insert_property_defaults, :default_additional_properties,
70
+ :query_array_explode
63
71
 
64
72
  # @!attribute [r] current_version
65
73
  # A callable object that returns the current API version to use when not explicitly specified
@@ -86,6 +94,7 @@ module Verquest
86
94
  @insert_property_defaults = true
87
95
  @custom_field_types = {}
88
96
  @default_additional_properties = false
97
+ @query_array_explode = true
89
98
  end
90
99
 
91
100
  # Sets the current version strategy using a callable object
@@ -151,6 +160,15 @@ module Verquest
151
160
  SCHEMAS[json_schema_version] || raise(ArgumentError, "Unsupported JSON Schema version: #{json_schema_version}")
152
161
  end
153
162
 
163
+ # Checks whether the configured JSON Schema version has the `const` keyword
164
+ #
165
+ # Draft 4 and OpenAPI 3.0 don't, so a constant is written as a one-value `enum` there.
166
+ #
167
+ # @return [Boolean] False for draft 4 and OpenAPI 3.0
168
+ def const_keyword?
169
+ !VERSIONS_WITHOUT_CONST.include?(json_schema_version)
170
+ end
171
+
154
172
  # Gets the JSON Schema URI for the configured schema version
155
173
  #
156
174
  # @return [String] The base URI for the configured JSON Schema version
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Verquest
4
- GEM_VERSION = "0.6.3"
4
+ GEM_VERSION = "0.7.0"
5
5
  end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module HelperMethods
5
+ # Module that adds conditions (`if` / `then` / `else`) to a scope
6
+ #
7
+ # Included in Version, Properties::Object and Properties::Collection (inline
8
+ # items). The including class must set `@conditions = []` and have a
9
+ # `properties` hash.
10
+ #
11
+ # The branch properties of all conditions are listed next to the regular
12
+ # properties ({#all_properties}), so `additionalProperties` keeps working and
13
+ # the mapping is the flat union of both. The scope schema gets the
14
+ # condition's `if` / `then` / `else` keywords, or one `allOf` entry per
15
+ # condition when there are several.
16
+ module ConditionalProperties
17
+ # Schema options that can't be combined with conditions
18
+ RESERVED_KEYWORDS = %w[if then else allOf].freeze
19
+
20
+ # JSON Schema versions that ignore `if` / `then` / `else` and would accept every payload
21
+ UNSUPPORTED_DIALECTS = %i[draft4 draft6 openapi30].freeze
22
+
23
+ # @!attribute [r] conditions
24
+ # @return [Array<Verquest::Conditions::Condition>] The conditions in declaration order
25
+ attr_reader :conditions
26
+
27
+ # Add a condition to this scope
28
+ #
29
+ # A named condition can be declared only once per scope. To replace an
30
+ # inherited one, call `exclude_conditions` first.
31
+ #
32
+ # @param condition [Verquest::Conditions::Condition] The condition to add
33
+ # @return [Array<Verquest::Conditions::Condition>] The conditions
34
+ # @raise [ArgumentError] If a condition with the same name is already defined
35
+ def add_condition(condition)
36
+ raise ArgumentError, "Condition '#{condition.name}' is already defined" if condition.name && conditions.any? { |other| other.name == condition.name }
37
+
38
+ conditions << condition
39
+ end
40
+
41
+ # Remove a named condition from this scope
42
+ #
43
+ # @param name [Symbol, String] The condition name
44
+ # @return [Verquest::Conditions::Condition] The removed condition
45
+ # @raise [ArgumentError] If no condition has that name
46
+ def remove_condition(name)
47
+ condition = conditions.find { |candidate| candidate.name == name.to_s }
48
+ raise ArgumentError, "Condition '#{name}' is not defined" unless condition
49
+
50
+ conditions.delete(condition)
51
+ end
52
+
53
+ # Returns the regular properties followed by the branch properties
54
+ #
55
+ # Computed on every call, so it always reflects the current declarations.
56
+ #
57
+ # @return [Hash<String, Verquest::Properties::Base>] All properties listed in the scope schema
58
+ # @raise [ArgumentError] If a branch property clashes with another declaration
59
+ def all_properties
60
+ properties.merge(conditional_properties)
61
+ end
62
+
63
+ private
64
+
65
+ # Merges the branch properties of all conditions
66
+ #
67
+ # @return [Hash<String, Verquest::Properties::Base>] The branch properties
68
+ # @raise [ArgumentError] If a branch property clashes with another declaration
69
+ def conditional_properties
70
+ Conditions::PropertyMerge.call(conditions.map(&:properties)).tap { |branch_properties| check_property_clashes!(branch_properties) }
71
+ end
72
+
73
+ # Checks that no branch property is also declared on the scope
74
+ #
75
+ # @param branch_properties [Hash<String, Verquest::Properties::Base>] The branch properties
76
+ # @return [void]
77
+ # @raise [ArgumentError] If a branch property is also declared on the scope
78
+ def check_property_clashes!(branch_properties)
79
+ clash = branch_properties.keys.find { |name| properties.key?(name) }
80
+ raise ArgumentError, "Property '#{clash}' is declared both outside and inside a condition" if clash
81
+ end
82
+
83
+ # Adds the conditional keywords to a scope schema
84
+ #
85
+ # In documentation, a condition declared with `document_as: :one_of`
86
+ # replaces the schema with a oneOf of its branches instead.
87
+ #
88
+ # @param schema [Hash] The scope schema, changed in place
89
+ # @param documentation [Boolean] Whether the schema is the documented one (`to_schema`)
90
+ # @return [Hash] The schema
91
+ # @raise [ArgumentError] If a condition is invalid or the schema already has conditional keywords
92
+ def add_conditions(schema, documentation: false)
93
+ return schema if conditions.empty?
94
+
95
+ validate_conditions(schema)
96
+ return schema.replace(conditions.first.to_one_of_schema(schema)) if documentation && conditions.first.document_as
97
+
98
+ schema.merge!(conditions_schema)
99
+ end
100
+
101
+ # Checks the conditions before they are added to the schema
102
+ #
103
+ # @param schema [Hash] The scope schema
104
+ # @return [void]
105
+ # @raise [ArgumentError] If the JSON Schema version ignores conditions, the schema already
106
+ # has conditional keywords, a condition documented as one_of is not alone, or a condition is invalid
107
+ def validate_conditions(schema)
108
+ dialect = Verquest.configuration.json_schema_version
109
+ raise ArgumentError, "Conditions need json_schema_version draft7 or later, or openapi31 (got #{dialect})" if UNSUPPORTED_DIALECTS.include?(dialect)
110
+ raise ArgumentError, "Raw if, then, else or allOf schema options can not be combined with conditions" if schema.keys.intersect?(RESERVED_KEYWORDS)
111
+ raise ArgumentError, "A condition documented as one_of must be the only condition in its version" if conditions.size > 1 && conditions.any?(&:document_as)
112
+
113
+ conditions.each { |condition| condition.validate!(all_properties.keys) }
114
+ end
115
+
116
+ # Builds the conditional keywords
117
+ #
118
+ # @return [Hash] `if` / `then` / `else` for one condition, `allOf` for several
119
+ def conditions_schema
120
+ return conditions.first.to_schema if conditions.one?
121
+
122
+ {"allOf" => conditions.map(&:to_schema)}
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module HelperMethods
5
+ # Checks that an unnamed oneOf is the only property in its scope
6
+ #
7
+ # An unnamed oneOf stands for the whole scope: the version, or a
8
+ # collection item. Its variants have nowhere to go next to other
9
+ # properties, so that is rejected when the properties are declared.
10
+ module UnnamedOneOf
11
+ # The error raised for an unnamed oneOf next to other properties
12
+ MESSAGE = "An unnamed one_of must be the only property in its scope. Give it a name: to declare it next to other properties"
13
+
14
+ private
15
+
16
+ # @param property [Verquest::Properties::Base] The property being added
17
+ # @return [void]
18
+ # @raise [ArgumentError] If an unnamed oneOf would share the scope with other properties
19
+ def check_unnamed_one_of(property)
20
+ # A property with the same name replaces the existing one, as in an inheriting version
21
+ others = properties.values.reject { |other| other.name.to_s == property.name.to_s }
22
+ return if others.empty?
23
+ return unless unnamed_one_of?(property) || others.any? { |other| unnamed_one_of?(other) }
24
+
25
+ raise ArgumentError, MESSAGE
26
+ end
27
+
28
+ # @param property [Verquest::Properties::Base] The property
29
+ # @return [Boolean] True for a oneOf without a name
30
+ def unnamed_one_of?(property)
31
+ property.is_a?(Properties::OneOf) && property.name.nil?
32
+ end
33
+ end
34
+ end
35
+ end
@@ -15,6 +15,9 @@ module Verquest
15
15
  # required: true
16
16
  # )
17
17
  class Array < Base
18
+ # @return [Boolean] In query requests, whether items are sent as repeated keys (true) or comma-separated (false)
19
+ attr_reader :explode
20
+
18
21
  # Initialize a new Array property
19
22
  #
20
23
  # @param name [String, Symbol] The name of the property
@@ -22,11 +25,12 @@ module Verquest
22
25
  # @param map [String, nil] The mapping path for this property (nil for no explicit mapping)
23
26
  # @param required [Boolean, Array<Symbol>] Whether this property is required, or array of dependency names
24
27
  # @param nullable [Boolean] Whether this property can be null
28
+ # @param explode [Boolean, nil] In query requests, whether items are sent as repeated keys (true) or comma-separated (false); nil uses the configuration
25
29
  # @param item_schema_options [Hash] Additional JSON schema options for the array items (merged with custom type options)
26
30
  # @param schema_options [Hash] Additional JSON schema options for the array property itself
27
31
  # @raise [ArgumentError] If type is not one of the allowed types (default or custom)
28
32
  # @raise [ArgumentError] If attempting to map an array to the root
29
- def initialize(name:, type:, map: nil, required: false, nullable: false, item_schema_options: {}, **schema_options)
33
+ def initialize(name:, type:, map: nil, required: false, nullable: false, explode: nil, item_schema_options: {}, **schema_options)
30
34
  raise ArgumentError, "Type must be one of #{allowed_types.join(", ")}" unless allowed_types.include?(type.to_s)
31
35
  raise ArgumentError, "You can not map array to the root" if map == "/"
32
36
 
@@ -46,6 +50,7 @@ module Verquest
46
50
  @map = map
47
51
  @required = required
48
52
  @nullable = nullable
53
+ @explode = explode.nil? ? Verquest.configuration.query_array_explode : explode
49
54
  @schema_options = schema_options&.transform_keys(&:to_s)
50
55
 
51
56
  @type = if nullable
@@ -78,6 +83,15 @@ module Verquest
78
83
  mapping[(key_prefix + [name]).join("/")] = mapping_value_key(value_prefix:)
79
84
  end
80
85
 
86
+ # Returns this array's explode value and parameter examples
87
+ #
88
+ # @param prefix [Array<String>] Path of the parent property
89
+ # @param version [String] The version to resolve references with
90
+ # @return [Hash{Array<String> => Hash}] The options of this array, by path
91
+ def query_options(prefix:, version:)
92
+ {prefix + [name] => {explode:, examples: parameter_examples}.compact}
93
+ end
94
+
81
95
  private
82
96
 
83
97
  attr_reader :type, :item_type, :schema_options, :item_schema_options
@@ -12,13 +12,18 @@ module Verquest
12
12
  class Base
13
13
  include HelperMethods::RequiredProperties
14
14
 
15
+ # JSON Schema for null type, used when nullable is true
16
+ NULL_TYPE_SCHEMA = {"type" => "null"}.freeze
17
+
15
18
  # @!attribute [rw] name
16
19
  # @return [String] The name of the property
17
20
  # @!attribute [rw] required
18
21
  # @return [Boolean] Whether this property is required
19
22
  # @!attribute [rw] map
20
23
  # @return [String, nil] The mapping path for this property
21
- attr_accessor :name, :required, :map
24
+ # @!attribute [rw] parameter_examples
25
+ # @return [Hash, nil] Named OpenAPI Example Objects for this property's query parameter, kept out of the JSON schemas
26
+ attr_accessor :name, :required, :map, :parameter_examples
22
27
 
23
28
  # Adds a child property to this property
24
29
  # @abstract
@@ -55,18 +60,74 @@ module Verquest
55
60
  raise NoMethodError
56
61
  end
57
62
 
63
+ # Adds this property to a transformer layout
64
+ #
65
+ # This is the layout of a scalar property: one source and target pair.
66
+ # Objects, collections, references and oneOfs override it.
67
+ #
68
+ # @param key_prefix [Array<String>] Prefix for the source key
69
+ # @param value_prefix [Array<String>] Prefix for the target value
70
+ # @param layout [Verquest::Transformer::Layout] The layout to add to
71
+ # @param version [String, nil] The version to create the layout for
72
+ # @return [void]
73
+ def layout(key_prefix:, value_prefix:, layout:, version:)
74
+ layout.add_pair((key_prefix + [name]).join("/"), mapping_value_key(value_prefix:))
75
+ end
76
+
77
+ # Returns the query parameter options under this property, by path
78
+ #
79
+ # Options are `explode:` for arrays and `examples:` for properties with
80
+ # parameter examples. Paths without options are left out.
81
+ #
82
+ # @param prefix [Array<String>] Path of the parent property
83
+ # @param version [String] The version to resolve references with
84
+ # @return [Hash{Array<String> => Hash}] The options of each property path
85
+ def query_options(prefix:, version:)
86
+ parameter_examples ? {prefix + [name] => {examples: parameter_examples}} : {}
87
+ end
88
+
89
+ # Returns a copy of this property under another name and map:
90
+ #
91
+ # Used by a reference to a single property, so the property maps as if
92
+ # it were declared where the reference is.
93
+ #
94
+ # @param name [String, Symbol] The new name
95
+ # @param map [String, nil] The new map: option
96
+ # @return [Verquest::Properties::Base] The renamed copy
97
+ def renamed(name:, map:)
98
+ dup.tap { |copy| copy.rename(name, map) }
99
+ end
100
+
101
+ protected
102
+
103
+ # @param name [String, Symbol] The new name
104
+ # @param map [String, nil] The new map: option
105
+ # @return [void]
106
+ def rename(name, map)
107
+ @name = name.to_s
108
+ @map = map
109
+ end
110
+
58
111
  private
59
112
 
60
113
  # @!attribute [r] nullable
61
114
  # @return [Boolean] Whether this property can be null
62
115
  attr_reader :nullable
63
116
 
117
+ # Allows null without weakening constraints, keeping defaults available for insertion
118
+ # @param schema [Hash] The original property schema
119
+ # @return [Hash] The schema, optionally wrapped in a nullable union
120
+ def nullable_schema(schema)
121
+ return schema unless nullable
122
+
123
+ schema.slice("default").merge("anyOf" => [schema.except("default"), NULL_TYPE_SCHEMA])
124
+ end
125
+
64
126
  # Determines the mapping target key based on mapping configuration
65
127
  # @param value_prefix [Array<String>] Prefix for the target value
66
- # @param collection [Boolean] Whether this is a collection mapping
67
128
  # @return [String] The target mapping key
68
- def mapping_value_key(value_prefix:, collection: false)
69
- value_key = if map.nil?
129
+ def mapping_value_key(value_prefix:)
130
+ if map.nil?
70
131
  (value_prefix + [name]).join("/")
71
132
  elsif map == "/"
72
133
  ""
@@ -75,12 +136,6 @@ module Verquest
75
136
  else
76
137
  (value_prefix + map.split("/")).join("/")
77
138
  end
78
-
79
- if collection
80
- value_key + "[]"
81
- else
82
- value_key
83
- end
84
139
  end
85
140
 
86
141
  # Determines the mapping target value prefix based on mapping configuration
@@ -105,6 +160,57 @@ module Verquest
105
160
 
106
161
  value_prefix
107
162
  end
163
+
164
+ # Adds the mapping of a child property
165
+ #
166
+ # A oneOf gets its own mapping in the `_oneOfs` array, so its metadata
167
+ # can't clash with another oneOf in the same mapping.
168
+ #
169
+ # @param property [Verquest::Properties::Base] The child property
170
+ # @param mapping [Hash] The mapping hash to be updated
171
+ # @param options [Hash] The key_prefix, value_prefix and version for the child mapping
172
+ # @return [void]
173
+ def add_child_mapping(property, mapping:, **options)
174
+ return property.mapping(mapping:, **options) unless property.is_a?(OneOf)
175
+
176
+ one_of_mapping = {}
177
+ property.mapping(mapping: one_of_mapping, **options)
178
+ (mapping["_oneOfs"] ||= []) << one_of_mapping
179
+ end
180
+
181
+ # Adds the mappings of a referenced request's properties under the given prefixes
182
+ #
183
+ # Each property maps itself with the prefixes, instead of the finished
184
+ # mapping of the request being copied, so its oneOfs keep their
185
+ # metadata and land in `_oneOfs`.
186
+ #
187
+ # @param request [Class<Verquest::Base>] The referenced request
188
+ # @param key_prefix [Array<String>] Prefix for the source keys
189
+ # @param value_prefix [Array<String>] Prefix for the target values
190
+ # @param mapping [Hash] The mapping hash to be updated
191
+ # @param version [String] The version to create mapping for
192
+ # @return [void]
193
+ def add_request_mapping(request, key_prefix:, value_prefix:, mapping:, version:)
194
+ referenced = request.resolve(version)
195
+ referenced.all_properties.each_value do |property|
196
+ add_child_mapping(property, key_prefix:, value_prefix:, mapping:, version: referenced.name)
197
+ end
198
+ end
199
+
200
+ # Adds a referenced request's properties to a layout under the given prefixes
201
+ #
202
+ # @param request [Class<Verquest::Base>] The referenced request
203
+ # @param key_prefix [Array<String>] Prefix for the source keys
204
+ # @param value_prefix [Array<String>] Prefix for the target values
205
+ # @param layout [Verquest::Transformer::Layout] The layout to add to
206
+ # @param version [String] The version to create the layout for
207
+ # @return [void]
208
+ def add_request_layout(request, key_prefix:, value_prefix:, layout:, version:)
209
+ referenced = request.resolve(version)
210
+ referenced.all_properties.each_value do |property|
211
+ property.layout(key_prefix:, value_prefix:, layout:, version: referenced.name)
212
+ end
213
+ end
108
214
  end
109
215
  end
110
216
  end