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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -0
  3. data/README.md +95 -42
  4. data/lib/openapi_first/builder.rb +85 -50
  5. data/lib/openapi_first/child_configuration.rb +0 -2
  6. data/lib/openapi_first/configuration.rb +0 -23
  7. data/lib/openapi_first/definition.rb +35 -2
  8. data/lib/openapi_first/failure.rb +5 -1
  9. data/lib/openapi_first/middlewares/request_validation.rb +1 -1
  10. data/lib/openapi_first/middlewares/response_validation.rb +1 -1
  11. data/lib/openapi_first/parameter/converter/array_converter.rb +42 -0
  12. data/lib/openapi_first/parameter/converter/object_converter.rb +60 -0
  13. data/lib/openapi_first/parameter/converter.rb +69 -0
  14. data/lib/openapi_first/parameter/unpackers.rb +132 -0
  15. data/lib/openapi_first/parameter.rb +70 -0
  16. data/lib/openapi_first/parameter_content_parsers.rb +55 -0
  17. data/lib/openapi_first/parameters_parser.rb +23 -0
  18. data/lib/openapi_first/query_string_parser.rb +93 -0
  19. data/lib/openapi_first/ref_resolver.rb +56 -3
  20. data/lib/openapi_first/request.rb +11 -10
  21. data/lib/openapi_first/request_body_parsers.rb +11 -7
  22. data/lib/openapi_first/request_headers.rb +27 -0
  23. data/lib/openapi_first/request_validator.rb +4 -1
  24. data/lib/openapi_first/response_header.rb +9 -0
  25. data/lib/openapi_first/response_parser.rb +3 -10
  26. data/lib/openapi_first/router.rb +27 -12
  27. data/lib/openapi_first/schema/hash.rb +0 -1
  28. data/lib/openapi_first/sinatra.rb +217 -0
  29. data/lib/openapi_first/test/configuration.rb +0 -34
  30. data/lib/openapi_first/test/coverage/html_reporter/context.rb +24 -17
  31. data/lib/openapi_first/test/coverage/html_reporter.css +214 -67
  32. data/lib/openapi_first/test/coverage/html_reporter.html.erb +39 -11
  33. data/lib/openapi_first/test/coverage/html_reporter.rb +11 -1
  34. data/lib/openapi_first/test/coverage/plan.rb +30 -10
  35. data/lib/openapi_first/test/coverage/request_task.rb +7 -2
  36. data/lib/openapi_first/test/coverage/response_task.rb +6 -1
  37. data/lib/openapi_first/test/coverage/route_task.rb +23 -1
  38. data/lib/openapi_first/test/coverage/skipped_summary.rb +22 -0
  39. data/lib/openapi_first/test/coverage/terminal_reporter.rb +23 -11
  40. data/lib/openapi_first/test/coverage.rb +9 -3
  41. data/lib/openapi_first/test.rb +69 -11
  42. data/lib/openapi_first/validators/multipart_request_body.rb +57 -0
  43. data/lib/openapi_first/validators/request_body.rb +20 -7
  44. data/lib/openapi_first/validators/request_parameters.rb +5 -4
  45. data/lib/openapi_first/version.rb +1 -1
  46. metadata +15 -23
  47. 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, OpenapiFirst.configuration.response_validation_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