openapi_first 3.4.3 → 4.0.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 +66 -0
- data/README.md +95 -42
- data/lib/openapi_first/builder.rb +85 -50
- data/lib/openapi_first/child_configuration.rb +0 -2
- data/lib/openapi_first/configuration.rb +0 -23
- data/lib/openapi_first/definition.rb +35 -2
- data/lib/openapi_first/failure.rb +5 -1
- data/lib/openapi_first/middlewares/request_validation.rb +1 -1
- data/lib/openapi_first/middlewares/response_validation.rb +1 -1
- data/lib/openapi_first/parameter/converter/array_converter.rb +42 -0
- data/lib/openapi_first/parameter/converter/object_converter.rb +60 -0
- data/lib/openapi_first/parameter/converter.rb +69 -0
- data/lib/openapi_first/parameter/unpackers.rb +132 -0
- data/lib/openapi_first/parameter.rb +70 -0
- data/lib/openapi_first/parameter_content_parsers.rb +55 -0
- data/lib/openapi_first/parameters_parser.rb +23 -0
- data/lib/openapi_first/query_string_parser.rb +93 -0
- data/lib/openapi_first/ref_resolver.rb +56 -3
- data/lib/openapi_first/request.rb +11 -10
- data/lib/openapi_first/request_body_parsers.rb +11 -7
- data/lib/openapi_first/request_headers.rb +27 -0
- data/lib/openapi_first/request_validator.rb +4 -1
- data/lib/openapi_first/response_header.rb +9 -0
- data/lib/openapi_first/response_parser.rb +3 -10
- data/lib/openapi_first/router.rb +27 -12
- data/lib/openapi_first/schema/hash.rb +0 -1
- data/lib/openapi_first/sinatra.rb +217 -0
- data/lib/openapi_first/test/configuration.rb +0 -34
- data/lib/openapi_first/test/coverage/html_reporter/context.rb +24 -17
- data/lib/openapi_first/test/coverage/html_reporter.css +214 -67
- data/lib/openapi_first/test/coverage/html_reporter.html.erb +39 -11
- data/lib/openapi_first/test/coverage/html_reporter.rb +11 -1
- data/lib/openapi_first/test/coverage/plan.rb +30 -10
- data/lib/openapi_first/test/coverage/request_task.rb +7 -2
- data/lib/openapi_first/test/coverage/response_task.rb +6 -1
- data/lib/openapi_first/test/coverage/route_task.rb +23 -1
- data/lib/openapi_first/test/coverage/skipped_summary.rb +22 -0
- data/lib/openapi_first/test/coverage/terminal_reporter.rb +23 -11
- data/lib/openapi_first/test/coverage.rb +9 -3
- data/lib/openapi_first/test.rb +69 -11
- data/lib/openapi_first/validators/multipart_request_body.rb +57 -0
- data/lib/openapi_first/validators/request_body.rb +20 -7
- data/lib/openapi_first/validators/request_parameters.rb +5 -4
- data/lib/openapi_first/version.rb +1 -1
- metadata +15 -23
- data/lib/openapi_first/header.rb +0 -9
|
@@ -20,7 +20,7 @@ module OpenapiFirst
|
|
|
20
20
|
options = spec
|
|
21
21
|
spec = options[:spec]
|
|
22
22
|
end
|
|
23
|
-
@raise = options.fetch(:raise_error,
|
|
23
|
+
@raise = options.fetch(:raise_error, true)
|
|
24
24
|
|
|
25
25
|
spec ||= :default
|
|
26
26
|
spec = OpenapiFirst[spec] if spec.is_a?(Symbol)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
class Parameter
|
|
5
|
+
module Converter
|
|
6
|
+
# Converts the items of an array parameter value
|
|
7
|
+
# @visibility private
|
|
8
|
+
ArrayConverter = Data.define(:schema) do
|
|
9
|
+
def call(value)
|
|
10
|
+
return [] if value.nil? || value.empty?
|
|
11
|
+
|
|
12
|
+
convert_array(value)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def convert_array(array)
|
|
18
|
+
return array unless array.is_a?(Array)
|
|
19
|
+
|
|
20
|
+
item_schema = schema['items']
|
|
21
|
+
prefix_schemas = schema['prefixItems']
|
|
22
|
+
return convert_array_with_prefixes(array, prefix_schemas, item_schema) if prefix_schemas
|
|
23
|
+
|
|
24
|
+
array.map { Converter.convert(_1, item_schema) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def convert_array_with_prefixes(array, prefix_schemas, item_schema)
|
|
28
|
+
prefixes =
|
|
29
|
+
array
|
|
30
|
+
.slice(0, prefix_schemas.size)
|
|
31
|
+
.each_with_index
|
|
32
|
+
.map { |item, index| Converter.convert(item, prefix_schemas[index]) }
|
|
33
|
+
array =
|
|
34
|
+
array[prefix_schemas.size..].map! do |item|
|
|
35
|
+
Converter.convert(item, item_schema)
|
|
36
|
+
end
|
|
37
|
+
prefixes + array
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
class Parameter
|
|
5
|
+
module Converter
|
|
6
|
+
# Converts the properties of an object parameter value
|
|
7
|
+
# @visibility private
|
|
8
|
+
ObjectConverter = Data.define(:schema) do
|
|
9
|
+
def self.get_properties(schema) # rubocop:disable Metrics
|
|
10
|
+
return nil if schema.nil? || schema.empty?
|
|
11
|
+
|
|
12
|
+
direct_props = schema['properties']
|
|
13
|
+
additional_props = schema['additionalProperties']
|
|
14
|
+
|
|
15
|
+
composition_props = []
|
|
16
|
+
|
|
17
|
+
%w[allOf oneOf anyOf].each do |keyword|
|
|
18
|
+
next unless (array = schema[keyword])
|
|
19
|
+
|
|
20
|
+
array.each do |sub_schema|
|
|
21
|
+
if (props = sub_schema['properties'])
|
|
22
|
+
composition_props << props
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
%w[then else].each do |keyword|
|
|
28
|
+
next unless (sub_schema = schema[keyword])
|
|
29
|
+
|
|
30
|
+
if (props = sub_schema['properties'])
|
|
31
|
+
composition_props << props
|
|
32
|
+
end
|
|
33
|
+
if (add_props = sub_schema['additionalProperties']) && add_props.is_a?(Hash) && !add_props.empty?
|
|
34
|
+
composition_props << add_props
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
composition_props << additional_props if additional_props.is_a?(Hash) && !additional_props.empty?
|
|
39
|
+
|
|
40
|
+
return direct_props if composition_props.empty? && direct_props
|
|
41
|
+
return nil if direct_props.nil? && composition_props.empty?
|
|
42
|
+
|
|
43
|
+
result = direct_props ? direct_props.dup : {}
|
|
44
|
+
composition_props.each { result.merge!(_1) }
|
|
45
|
+
result
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def call(value)
|
|
49
|
+
return value unless value.is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
properties = self.class.get_properties(schema)
|
|
52
|
+
|
|
53
|
+
value.each_with_object({}) do |(key, val), hsh|
|
|
54
|
+
hsh[key] = Converter.convert(val, properties&.fetch(key, nil))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'converter/array_converter'
|
|
4
|
+
require_relative 'converter/object_converter'
|
|
5
|
+
|
|
6
|
+
module OpenapiFirst
|
|
7
|
+
class Parameter
|
|
8
|
+
# Converts a parameter value (string) to the type specified in the JSON Schema.
|
|
9
|
+
# @visibility private
|
|
10
|
+
module Converter
|
|
11
|
+
PASS_THROUGH = ->(value) { value }
|
|
12
|
+
|
|
13
|
+
INTEGER = lambda do |value|
|
|
14
|
+
Integer(value, 10)
|
|
15
|
+
rescue StandardError
|
|
16
|
+
value
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
NUMBER = lambda do |value|
|
|
20
|
+
Float(value)
|
|
21
|
+
rescue StandardError
|
|
22
|
+
value
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
BOOLEAN = lambda do |value|
|
|
26
|
+
if value == 'true'
|
|
27
|
+
true
|
|
28
|
+
else
|
|
29
|
+
value == 'false' ? false : value
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# Returns a callable that converts a value as described in the schema
|
|
35
|
+
# @param schema [Hash, nil]
|
|
36
|
+
def [](schema)
|
|
37
|
+
case schema && schema['type']
|
|
38
|
+
when 'integer' then INTEGER
|
|
39
|
+
when 'number' then NUMBER
|
|
40
|
+
when 'boolean' then BOOLEAN
|
|
41
|
+
when 'object' then ObjectConverter.new(schema)
|
|
42
|
+
when 'array' then ArrayConverter.new(schema)
|
|
43
|
+
else
|
|
44
|
+
return ObjectConverter.new(schema) if object_like?(schema)
|
|
45
|
+
|
|
46
|
+
PASS_THROUGH
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Converts a nested value, like an array item or an object property
|
|
51
|
+
def convert(value, schema)
|
|
52
|
+
return if value.nil?
|
|
53
|
+
return value if schema.nil?
|
|
54
|
+
|
|
55
|
+
self[schema].call(value)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
OBJECT_KEYWORDS = %w[properties oneOf allOf anyOf].freeze
|
|
61
|
+
private_constant :OBJECT_KEYWORDS
|
|
62
|
+
|
|
63
|
+
def object_like?(schema)
|
|
64
|
+
schema && OBJECT_KEYWORDS.any? { schema[_1] }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack'
|
|
4
|
+
require_relative '../parameter_content_parsers'
|
|
5
|
+
|
|
6
|
+
module OpenapiFirst
|
|
7
|
+
class Parameter
|
|
8
|
+
# Resolves the unpacker of a parameter once, at Parameter construction time.
|
|
9
|
+
# Each unpacker is a callable that takes a raw string (or already-parsed
|
|
10
|
+
# value) and returns the unpacked Ruby value, throwing :skip on unrecoverable
|
|
11
|
+
# parse errors.
|
|
12
|
+
# @visibility private
|
|
13
|
+
module Unpackers
|
|
14
|
+
ARRAY_DELIMITER = {
|
|
15
|
+
'label' => '.',
|
|
16
|
+
'simple' => ',',
|
|
17
|
+
'form' => ',',
|
|
18
|
+
'pipeDelimited' => '|',
|
|
19
|
+
'spaceDelimited' => ' '
|
|
20
|
+
}.freeze
|
|
21
|
+
private_constant :ARRAY_DELIMITER
|
|
22
|
+
|
|
23
|
+
PREFIXED_STYLES = %w[label matrix].freeze
|
|
24
|
+
private_constant :PREFIXED_STYLES
|
|
25
|
+
|
|
26
|
+
OBJECT_EXPLODE_SPLITTER = Regexp.union(',', '=').freeze
|
|
27
|
+
private_constant :OBJECT_EXPLODE_SPLITTER
|
|
28
|
+
|
|
29
|
+
PassThrough = ->(value) { value }
|
|
30
|
+
|
|
31
|
+
DelimitedArray = Data.define(:delimiter, :strip_prefix) do
|
|
32
|
+
def call(value)
|
|
33
|
+
return value if value.is_a?(::Array)
|
|
34
|
+
return value if value.empty?
|
|
35
|
+
|
|
36
|
+
value = value[1..] if strip_prefix
|
|
37
|
+
value.split(delimiter)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
MatrixArray = Data.define(:name, :explode) do
|
|
42
|
+
def call(value)
|
|
43
|
+
return value if value.is_a?(::Array)
|
|
44
|
+
return value if value.empty?
|
|
45
|
+
|
|
46
|
+
result = Unpackers.parse_query(value, ';')[name]
|
|
47
|
+
return result if explode
|
|
48
|
+
return result unless result.is_a?(::String)
|
|
49
|
+
|
|
50
|
+
result.split(',')
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
ExplodeFormObject = lambda do |value|
|
|
55
|
+
throw :skip, value unless value.is_a?(::String)
|
|
56
|
+
|
|
57
|
+
entries = value.split(OBJECT_EXPLODE_SPLITTER)
|
|
58
|
+
throw :skip, value if entries.length.odd?
|
|
59
|
+
|
|
60
|
+
Hash[*entries]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
DelimitedObject = Data.define(:delimiter) do
|
|
64
|
+
def call(value)
|
|
65
|
+
throw :skip, value unless value.is_a?(::String)
|
|
66
|
+
|
|
67
|
+
entries = value.split(delimiter)
|
|
68
|
+
throw :skip, value if entries.length.odd?
|
|
69
|
+
|
|
70
|
+
Hash[*entries]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
ExplodePathObject = ->(value) { Unpackers.parse_query(value, ',') }
|
|
75
|
+
|
|
76
|
+
NonExplodePathObject = Data.define(:array_unpacker) do
|
|
77
|
+
def call(value)
|
|
78
|
+
array = array_unpacker.call(value)
|
|
79
|
+
throw :skip, value if array.length.odd?
|
|
80
|
+
|
|
81
|
+
Hash[*array]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
class << self
|
|
86
|
+
# Values that are not encoded as described are left to schema validation
|
|
87
|
+
def parse_query(value, delimiter)
|
|
88
|
+
Rack::Utils.parse_query(value, delimiter)
|
|
89
|
+
rescue ArgumentError
|
|
90
|
+
throw :skip, value
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def find(parameter)
|
|
94
|
+
return find_media_type(parameter) if parameter.media_type
|
|
95
|
+
return find_array(parameter) if parameter.array?
|
|
96
|
+
return find_object(parameter) if parameter.object?
|
|
97
|
+
|
|
98
|
+
PassThrough
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def find_media_type(parameter)
|
|
104
|
+
ParameterContentParsers[parameter.media_type] || PassThrough
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def find_array(parameter)
|
|
108
|
+
style = parameter.style
|
|
109
|
+
return MatrixArray.new(name: parameter.name, explode: parameter.explode?) if style == 'matrix'
|
|
110
|
+
|
|
111
|
+
DelimitedArray.new(
|
|
112
|
+
delimiter: ARRAY_DELIMITER[style],
|
|
113
|
+
strip_prefix: PREFIXED_STYLES.include?(style)
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def find_object(parameter)
|
|
118
|
+
return find_path_object(parameter) if parameter.location == 'path'
|
|
119
|
+
return ExplodeFormObject if parameter.explode?
|
|
120
|
+
|
|
121
|
+
DelimitedObject.new(delimiter: ARRAY_DELIMITER[parameter.style])
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def find_path_object(parameter)
|
|
125
|
+
return ExplodePathObject if parameter.explode?
|
|
126
|
+
|
|
127
|
+
NonExplodePathObject.new(array_unpacker: find_array(parameter))
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'parameter/converter'
|
|
4
|
+
require_relative 'parameter/unpackers'
|
|
5
|
+
|
|
6
|
+
module OpenapiFirst
|
|
7
|
+
# A parameter of a request, or a header of a response.
|
|
8
|
+
class Parameter
|
|
9
|
+
DEFAULT_STYLE = {
|
|
10
|
+
'query' => 'form',
|
|
11
|
+
'path' => 'simple',
|
|
12
|
+
'header' => 'simple',
|
|
13
|
+
'cookie' => 'form'
|
|
14
|
+
}.freeze
|
|
15
|
+
private_constant :DEFAULT_STYLE
|
|
16
|
+
|
|
17
|
+
# @param definition [Hash] The OpenAPI Parameter Object. A string keyed Hash.
|
|
18
|
+
# @param schema [Hash, nil] The resolved JSON Schema of this parameter.
|
|
19
|
+
def initialize(definition, schema:)
|
|
20
|
+
@name = definition['name']
|
|
21
|
+
@schema = schema
|
|
22
|
+
@location = definition['in']
|
|
23
|
+
@media_type = definition['content']&.keys&.first
|
|
24
|
+
@style = definition['style'] || DEFAULT_STYLE.fetch(@location)
|
|
25
|
+
@explode = definition.fetch('explode') { @style == 'form' }
|
|
26
|
+
@deep_object = @style == 'deepObject'
|
|
27
|
+
@required = @location == 'path' || definition['required'] == true
|
|
28
|
+
@deprecated = definition['deprecated'] == true
|
|
29
|
+
@converter = Converter[schema]
|
|
30
|
+
@unpacker = Unpackers.find(self)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :name, :schema, :location, :media_type, :style
|
|
34
|
+
|
|
35
|
+
def unpack(value)
|
|
36
|
+
return value if value.nil?
|
|
37
|
+
|
|
38
|
+
@unpacker.call(value)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def convert(value) = @converter.call(value)
|
|
42
|
+
|
|
43
|
+
# Unpacks and converts a raw value.
|
|
44
|
+
# For a query parameter, a value that cannot be unpacked is still converted.
|
|
45
|
+
# For any other location, such a value is returned as it is, unconverted.
|
|
46
|
+
def unpack_and_convert(value)
|
|
47
|
+
return convert(catch(:skip) { unpack(value) }) if location == 'query'
|
|
48
|
+
|
|
49
|
+
catch(:skip) { convert(unpack(value)) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def explode? = @explode
|
|
53
|
+
|
|
54
|
+
def required? = @required
|
|
55
|
+
|
|
56
|
+
def deprecated? = @deprecated
|
|
57
|
+
|
|
58
|
+
def deep_object? = @deep_object
|
|
59
|
+
|
|
60
|
+
def type = schema && schema['type']
|
|
61
|
+
|
|
62
|
+
def array? = type == 'array'
|
|
63
|
+
|
|
64
|
+
def object? = type == 'object' || deep_object? || schema&.key?('properties')
|
|
65
|
+
|
|
66
|
+
# The properties of an object parameter, merged across composition keywords.
|
|
67
|
+
# @return [Hash, nil]
|
|
68
|
+
def object_properties = Converter::ObjectConverter.get_properties(schema)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module OpenapiFirst
|
|
6
|
+
# Registry of parsers for parameters that use a `content` field, keyed by media type.
|
|
7
|
+
#
|
|
8
|
+
# A parser is a callable that takes a raw string and returns the parsed value.
|
|
9
|
+
# It should `throw :skip, value` if the input cannot be parsed, so the
|
|
10
|
+
# parameter value is used as is.
|
|
11
|
+
#
|
|
12
|
+
# OpenapiFirst::ParameterContentParsers.register('application/xml', ->(value) { ... })
|
|
13
|
+
module ParameterContentParsers
|
|
14
|
+
@parsers = []
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
attr_reader :parsers
|
|
18
|
+
|
|
19
|
+
# @param matcher [String, Regexp] exact media type or a pattern.
|
|
20
|
+
# @param parser [#call] callable that takes the raw string and returns the parsed value.
|
|
21
|
+
def register(matcher, parser)
|
|
22
|
+
parsers.reject! { |existing, _| existing == matcher }
|
|
23
|
+
parsers << [matcher, parser]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param media_type [String, nil]
|
|
27
|
+
# @return [#call, nil] the parser, or nil if none is registered.
|
|
28
|
+
def [](media_type)
|
|
29
|
+
return nil if media_type.nil?
|
|
30
|
+
|
|
31
|
+
parsers.each do |matcher, parser|
|
|
32
|
+
return parser if match?(matcher, media_type)
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def match?(matcher, media_type)
|
|
40
|
+
case matcher
|
|
41
|
+
when Regexp then matcher.match?(media_type)
|
|
42
|
+
else matcher == media_type
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
register(%r{\A[\w.+-]+/(?:[\w.-]+\+)?json\z}i, lambda do |value|
|
|
48
|
+
throw :skip, value unless value.is_a?(String)
|
|
49
|
+
|
|
50
|
+
JSON.parse(value)
|
|
51
|
+
rescue JSON::ParserError
|
|
52
|
+
throw :skip, value
|
|
53
|
+
end)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
# Unpacks parameters from a Hash of raw values, like path parameters, headers or cookies.
|
|
5
|
+
# @visibility private
|
|
6
|
+
class ParametersParser
|
|
7
|
+
# @param parameters [Array<Parameter>]
|
|
8
|
+
def initialize(parameters)
|
|
9
|
+
@parameters = parameters
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
attr_reader :parameters
|
|
13
|
+
|
|
14
|
+
# @param parameters_hash [Hash] The raw values, keyed by parameter name.
|
|
15
|
+
def unpack(parameters_hash)
|
|
16
|
+
parameters.each_with_object({}) do |parameter, result|
|
|
17
|
+
next unless parameters_hash.key?(parameter.name)
|
|
18
|
+
|
|
19
|
+
result[parameter.name] = parameter.unpack_and_convert(parameters_hash[parameter.name])
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack'
|
|
4
|
+
require_relative 'parameters_parser'
|
|
5
|
+
|
|
6
|
+
module OpenapiFirst
|
|
7
|
+
# Unpacks query parameters from a query string.
|
|
8
|
+
# @visibility private
|
|
9
|
+
class QueryStringParser
|
|
10
|
+
DEEP_PROP = '\[([\w-]+)\]$'
|
|
11
|
+
private_constant :DEEP_PROP
|
|
12
|
+
|
|
13
|
+
# @param parameters [Array<Parameter>]
|
|
14
|
+
def initialize(parameters)
|
|
15
|
+
@parameters = parameters
|
|
16
|
+
@deep_object_parameters, flat_parameters = parameters.partition(&:deep_object?)
|
|
17
|
+
@flat_parser = ParametersParser.new(flat_parameters)
|
|
18
|
+
@deep_object_properties = {}
|
|
19
|
+
@deep_object_regex = {}
|
|
20
|
+
@deep_object_parameters.each do |parameter|
|
|
21
|
+
@deep_object_properties[parameter.name] = parameter.object_properties
|
|
22
|
+
@deep_object_regex[parameter.name] = /^#{Regexp.escape(parameter.name)}#{DEEP_PROP}/
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :parameters
|
|
27
|
+
|
|
28
|
+
def unpack(query_string)
|
|
29
|
+
parsed_query = parse_query(query_string)
|
|
30
|
+
result = @flat_parser.unpack(parsed_query)
|
|
31
|
+
@deep_object_parameters.each do |parameter|
|
|
32
|
+
name = parameter.name
|
|
33
|
+
if parsed_query.key?(name)
|
|
34
|
+
result[name] = parameter.convert(parsed_query[name])
|
|
35
|
+
else
|
|
36
|
+
value = parse_deep_object(parameter, parsed_query)
|
|
37
|
+
result[name] = parameter.convert(value) unless value.empty?
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
result
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns query parameters that are not defined in the API description
|
|
44
|
+
def unknown_values(query_string)
|
|
45
|
+
parsed_query = parse_query(query_string)
|
|
46
|
+
known_parameter_names = parameters.to_set(&:name)
|
|
47
|
+
|
|
48
|
+
unknown = parsed_query.each_with_object({}) do |(key, value), result|
|
|
49
|
+
next if known_parameter_names.include?(key)
|
|
50
|
+
next if @deep_object_parameters.any? { key.start_with?("#{_1.name}[") }
|
|
51
|
+
|
|
52
|
+
result[key] = value
|
|
53
|
+
end
|
|
54
|
+
return if unknown.empty?
|
|
55
|
+
|
|
56
|
+
unknown
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def parse_query(query_string)
|
|
62
|
+
Rack::Utils.parse_query(query_string) do |string|
|
|
63
|
+
Rack::Utils.unescape(string)
|
|
64
|
+
rescue ArgumentError => e
|
|
65
|
+
raise Rack::Utils::InvalidParameterError, e.message
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse_deep_object(parameter, parsed_query)
|
|
70
|
+
name = parameter.name
|
|
71
|
+
prop_regx = @deep_object_regex[name]
|
|
72
|
+
properties_schema = @deep_object_properties[name]
|
|
73
|
+
|
|
74
|
+
parsed_query.each.with_object({}) do |(key, value), result|
|
|
75
|
+
prop_key = key.match(prop_regx)&.[](1)
|
|
76
|
+
next if prop_key.nil?
|
|
77
|
+
|
|
78
|
+
is_array = properties_schema&.dig(prop_key, 'type') == 'array'
|
|
79
|
+
result[prop_key] = explode_value(value, parameter, is_array)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def explode_value(value, parameter, is_array)
|
|
84
|
+
value = Array(value)
|
|
85
|
+
if is_array
|
|
86
|
+
return value if parameter.explode?
|
|
87
|
+
|
|
88
|
+
return [value.last]
|
|
89
|
+
end
|
|
90
|
+
value.last
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -84,7 +84,7 @@ module OpenapiFirst
|
|
|
84
84
|
def resolve_ref(pointer)
|
|
85
85
|
if pointer.start_with?('#')
|
|
86
86
|
value = Hana::Pointer.new(pointer[1..]).eval(context)
|
|
87
|
-
raise "Unknown reference #{pointer} in #{context}" unless value
|
|
87
|
+
raise "Unknown reference #{pointer.inspect} in #{filepath || context}" unless value
|
|
88
88
|
|
|
89
89
|
return ref_resolver.for(value, filepath:, context:)
|
|
90
90
|
end
|
|
@@ -93,12 +93,19 @@ module OpenapiFirst
|
|
|
93
93
|
full_path = File.expand_path(relative_path, dir)
|
|
94
94
|
return ref_resolver.load(full_path) unless file_pointer
|
|
95
95
|
|
|
96
|
-
ref_resolver.file_at(full_path, file_pointer)
|
|
96
|
+
resolved = ref_resolver.file_at(full_path, file_pointer)
|
|
97
|
+
raise "Unknown reference #{pointer.inspect} in #{filepath || context}" unless resolved
|
|
98
|
+
|
|
99
|
+
resolved
|
|
97
100
|
rescue OpenapiFirst::FileNotFoundError => e
|
|
98
101
|
message = "Problem with reference resolving #{pointer.inspect} in " \
|
|
99
102
|
"file #{File.absolute_path(filepath).inspect}: #{e.message}"
|
|
100
103
|
raise OpenapiFirst::FileNotFoundError, message
|
|
101
104
|
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def mark(visited) = (visited || []) + [value.object_id]
|
|
102
109
|
end
|
|
103
110
|
|
|
104
111
|
# @visibility private
|
|
@@ -106,6 +113,8 @@ module OpenapiFirst
|
|
|
106
113
|
include Resolvable
|
|
107
114
|
|
|
108
115
|
def resolved = value
|
|
116
|
+
|
|
117
|
+
def dereferenced(_visited = nil) = value
|
|
109
118
|
end
|
|
110
119
|
|
|
111
120
|
# @visibility private
|
|
@@ -124,6 +133,19 @@ module OpenapiFirst
|
|
|
124
133
|
value
|
|
125
134
|
end
|
|
126
135
|
|
|
136
|
+
# Returns a plain Hash with all nested $refs resolved.
|
|
137
|
+
# A node that is reached again on its own path, as in a recursive schema,
|
|
138
|
+
# is returned unresolved to stop the recursion.
|
|
139
|
+
# @param visited [Array<Integer>, nil] Object ids of the nodes on the current path.
|
|
140
|
+
def dereferenced(visited = nil)
|
|
141
|
+
return value if visited&.include?(value.object_id)
|
|
142
|
+
|
|
143
|
+
visited = mark(visited)
|
|
144
|
+
return resolve_ref(value['$ref'])&.dereferenced(visited) if value.key?('$ref')
|
|
145
|
+
|
|
146
|
+
value.each_key.to_h { |key| [key, self[key]&.dereferenced(visited)] }
|
|
147
|
+
end
|
|
148
|
+
|
|
127
149
|
def [](key)
|
|
128
150
|
return resolve_ref(@value['$ref'])[key] if !@value.key?(key) && @value.key?('$ref')
|
|
129
151
|
|
|
@@ -154,6 +176,14 @@ module OpenapiFirst
|
|
|
154
176
|
class Schema
|
|
155
177
|
extend Forwardable
|
|
156
178
|
|
|
179
|
+
# The root context is a document, not a schema. Parsing it with only the core vocabulary
|
|
180
|
+
# keeps document keys that collide with dialect keywords (like "id" in OpenAPI 3.0)
|
|
181
|
+
# from being parsed as such and keeps them navigable for $ref pointers.
|
|
182
|
+
DOCUMENT_META_SCHEMA = JSONSchemer::Schema.new(
|
|
183
|
+
{},
|
|
184
|
+
vocabulary: { 'https://json-schema.org/draft/2020-12/vocab/core' => true }
|
|
185
|
+
)
|
|
186
|
+
|
|
157
187
|
def initialize(value:, context:, base_uri:, options:)
|
|
158
188
|
@value = value
|
|
159
189
|
@context = context
|
|
@@ -167,10 +197,24 @@ module OpenapiFirst
|
|
|
167
197
|
|
|
168
198
|
def schema
|
|
169
199
|
@schema ||= begin
|
|
170
|
-
root_schema = JSONSchemer::Schema.new(context, base_uri:, **options)
|
|
200
|
+
root_schema = JSONSchemer::Schema.new(context, base_uri:, **options, meta_schema: DOCUMENT_META_SCHEMA)
|
|
201
|
+
apply_dialect(root_schema)
|
|
171
202
|
JSONSchemer::Schema.new(value, nil, root_schema, base_uri:, **options)
|
|
172
203
|
end
|
|
173
204
|
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
# Set the dialect meta schema on the root like JSONSchemer::Schema#parse would,
|
|
209
|
+
# so that schemas resolved via $ref pointers into the document inherit it.
|
|
210
|
+
def apply_dialect(root_schema)
|
|
211
|
+
dialect = options[:meta_schema] || options.fetch(:configuration, JSONSchemer.configuration).meta_schema
|
|
212
|
+
if dialect.is_a?(String)
|
|
213
|
+
JSONSchemer::Schema::SCHEMA_KEYWORD_CLASS.new(dialect, root_schema, '$schema')
|
|
214
|
+
else
|
|
215
|
+
root_schema.meta_schema = dialect
|
|
216
|
+
end
|
|
217
|
+
end
|
|
174
218
|
end
|
|
175
219
|
|
|
176
220
|
# @visibility private
|
|
@@ -201,6 +245,15 @@ module OpenapiFirst
|
|
|
201
245
|
end
|
|
202
246
|
end
|
|
203
247
|
end
|
|
248
|
+
|
|
249
|
+
# Returns a plain Array with all nested $refs resolved.
|
|
250
|
+
# @param visited [Array<Integer>, nil] Object ids of the nodes on the current path.
|
|
251
|
+
def dereferenced(visited = nil)
|
|
252
|
+
return value if visited&.include?(value.object_id)
|
|
253
|
+
|
|
254
|
+
visited = mark(visited)
|
|
255
|
+
value.each_index.map { self[_1]&.dereferenced(visited) }
|
|
256
|
+
end
|
|
204
257
|
end
|
|
205
258
|
end
|
|
206
259
|
end
|