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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -1
- data/README.md +299 -45
- data/Rakefile +5 -0
- data/docs/rails.md +169 -0
- data/lib/verquest/base/condition_class_methods.rb +211 -0
- data/lib/verquest/base/private_class_methods.rb +31 -14
- data/lib/verquest/base/public_class_methods.rb +51 -1
- data/lib/verquest/base.rb +1 -0
- data/lib/verquest/conditions/branch.rb +111 -0
- data/lib/verquest/conditions/condition.rb +194 -0
- data/lib/verquest/conditions/conjunction.rb +39 -0
- data/lib/verquest/conditions/fragment_merge.rb +52 -0
- data/lib/verquest/conditions/property_merge.rb +47 -0
- data/lib/verquest/conditions/rule_set.rb +58 -0
- data/lib/verquest/conditions/rules/absent.rb +25 -0
- data/lib/verquest/conditions/rules/alternatives.rb +28 -0
- data/lib/verquest/conditions/rules/any.rb +27 -0
- data/lib/verquest/conditions/rules/equals.rb +22 -0
- data/lib/verquest/conditions/rules/names_rule.rb +23 -0
- data/lib/verquest/conditions/rules/none.rb +24 -0
- data/lib/verquest/conditions/rules/present.rb +22 -0
- data/lib/verquest/conditions/rules/property_rule.rb +48 -0
- data/lib/verquest/conditions/rules/satisfies.rb +22 -0
- data/lib/verquest/conditions/rules/value_in.rb +22 -0
- data/lib/verquest/conditions/rules.rb +17 -0
- data/lib/verquest/conditions.rb +36 -0
- data/lib/verquest/configuration.rb +19 -1
- data/lib/verquest/gem_version.rb +1 -1
- data/lib/verquest/helper_methods/conditional_properties.rb +126 -0
- data/lib/verquest/helper_methods/unnamed_one_of.rb +35 -0
- data/lib/verquest/properties/array.rb +15 -1
- data/lib/verquest/properties/base.rb +104 -10
- data/lib/verquest/properties/collection.rb +51 -18
- data/lib/verquest/properties/const.rb +2 -1
- data/lib/verquest/properties/object.rb +38 -4
- data/lib/verquest/properties/one_of/discriminated_schema.rb +84 -0
- data/lib/verquest/properties/one_of.rb +83 -44
- data/lib/verquest/properties/reference.rb +47 -16
- data/lib/verquest/query/coercer.rb +165 -0
- data/lib/verquest/query/definition_check.rb +91 -0
- data/lib/verquest/query/parameters.rb +79 -0
- data/lib/verquest/query.rb +25 -0
- data/lib/verquest/transformer/collection.rb +32 -0
- data/lib/verquest/transformer/const_as_enum.rb +47 -0
- data/lib/verquest/transformer/layout.rb +83 -0
- data/lib/verquest/transformer/mapping_layout.rb +166 -0
- data/lib/verquest/transformer/one_of.rb +111 -0
- data/lib/verquest/transformer/path.rb +188 -0
- data/lib/verquest/transformer/plan.rb +183 -0
- data/lib/verquest/transformer/scope.rb +64 -0
- data/lib/verquest/transformer.rb +43 -880
- data/lib/verquest/version.rb +152 -79
- data/lib/verquest.rb +6 -0
- 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
|