openapi_first 2.1.1 → 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ceb641d45921bafb376d7fe7ea3a2f42b76c8900a5d8598e6ef018afca0304e
4
- data.tar.gz: 123c15a5a4a149e69a40773b0a62543257f3145fbc06c788207e65757ba3c404
3
+ metadata.gz: b6f86609c16829b7f0f94d837167a44e4cf93b38a392ac34b93a9ff9d7a794fc
4
+ data.tar.gz: 1524298280aa89e6cbae16ce80259ed1d0928b4018747eb6c3385ebed0e8db8b
5
5
  SHA512:
6
- metadata.gz: 3e279fa5f4239b1f41020b788370377535ef5cf959d2f2a23df61240310d4b729484e0a1154ff11250e21164c15b1ab285b3173fc2f2e3ac84603a5722e60ec7
7
- data.tar.gz: f96790f87324ea1ce7f78b6d3b41f86de709f990edf26a97ce1ad01b7929229198f9114ceb33d1aad94912211253ddc26dce6acae5566b386c805bb1219d886d
6
+ metadata.gz: a0d30f3482838dd995f44b5ede610ada74ff1a5d2c2e29a1b3fa109da98cbc5cd42bf8616b4f7e6c79b558d1c16b77ca1eb85b4fc82bcbd9e40c6dcf75aea927
7
+ data.tar.gz: 7f87349cd6fa98cef19f929db3deeb1c94a9b2eed759f23f592ba145c315726cfe8ff3f5fb3f50444b566a5d95b94370439015fd995554ecececc7029adff9ec
data/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 2.2.0
6
+
7
+ - Fix support for discriminator in response bodies if no mapping is defined (https://github.com/ahx/openapi_first/issues/285)
8
+ - Fix support for discriminator in request bodies if no mapping is defined
9
+ - Replace bundled json_refs fork with own code
10
+ - Better error messages when OpenAPI file has invalid references ("$ref")
11
+ - Autoload OpenapiFirst::Test module. There is no need to `require 'openapi_first/test'` anymore.
12
+ - Remove multi_json dependency. openapi_first uses multi_json if available or the default json gem otherwise.
13
+ If you want to use multi_json, make sure to add it to your Gemfile.
14
+
5
15
  ## 2.1.1
6
16
 
7
17
  - Fix issue with non file downloads / JSON responses https://github.com/ahx/openapi_first/issues/281
data/README.md CHANGED
@@ -79,8 +79,6 @@ validated_response.parsed_headers
79
79
  definition.validate_response(rack_request,rack_response, raise_error: true) # Raises OpenapiFirst::ResponseInvalidError or OpenapiFirst::ResponseNotFoundError
80
80
  ```
81
81
 
82
- OpenapiFirst uses [`multi_json`](https://rubygems.org/gems/multi_json).
83
-
84
82
  ## Rack Middlewares
85
83
 
86
84
  ### Request validation
@@ -210,7 +208,6 @@ Here is how to set it up for Rails integration tests:
210
208
 
211
209
  ```ruby
212
210
  # test_helper.rb
213
- require 'openapi_first/test'
214
211
  OpenapiFirst::Test.register('openapi/v1.openapi.yaml')
215
212
  ```
216
213
 
@@ -1,32 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'multi_json'
4
-
5
3
  module OpenapiFirst
6
4
  # @!visibility private
7
- class BodyParser
8
- def initialize(content_type)
9
- @is_json = :json if /json/i.match?(content_type)
5
+ module BodyParser
6
+ def self.[](content_type)
7
+ case content_type
8
+ when /json/i
9
+ JsonBodyParser
10
+ when %r{multipart/form-data}i
11
+ MultipartBodyParser
12
+ else
13
+ DefaultBodyParser
14
+ end
15
+ end
16
+
17
+ def self.read_body(request)
18
+ body = request.body&.read
19
+ request.body.rewind if request.body.respond_to?(:rewind)
20
+ body
10
21
  end
11
22
 
12
- def parse(request)
23
+ JsonBodyParser = lambda do |request|
13
24
  body = read_body(request)
14
25
  return if body.nil? || body.empty?
15
26
 
16
- return MultiJson.load(body) if @is_json
17
- return request.POST if request.form_data?
18
-
19
- body
20
- rescue MultiJson::ParseError
27
+ JSON.parse(body)
28
+ rescue JSON::ParserError
21
29
  Failure.fail!(:invalid_body, message: 'Failed to parse request body as JSON')
22
30
  end
23
31
 
24
- private
32
+ MultipartBodyParser = lambda do |request|
33
+ request.POST.transform_values do |value|
34
+ value.is_a?(Hash) && value[:tempfile] ? value[:tempfile].read : value
35
+ end
36
+ end
25
37
 
26
- def read_body(request)
27
- body = request.body&.read
28
- request.body.rewind if request.body.respond_to?(:rewind)
29
- body
38
+ # This returns the post data parsed by rack or the raw body
39
+ DefaultBodyParser = lambda do |request|
40
+ return request.POST if request.form_data?
41
+
42
+ read_body(request)
30
43
  end
31
44
  end
32
45
  end
@@ -1,33 +1,59 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json_schemer'
4
+ require_relative 'json_pointer'
5
+ require_relative 'ref_resolver'
6
+
3
7
  module OpenapiFirst
4
8
  # Builds parts of a Definition
5
9
  # This knows how to read a resolved OpenAPI document and build {Request} and {Response} objects.
6
- class Builder
10
+ class Builder # rubocop:disable Metrics/ClassLength
7
11
  REQUEST_METHODS = %w[get head post put patch delete trace options].freeze
8
12
 
9
13
  # Builds a router from a resolved OpenAPI document.
10
- # @param resolved [Hash] The resolved OpenAPI document.
14
+ # @param contents [Hash] The OpenAPI document Hash.
11
15
  # @param config [OpenapiFirst::Configuration] The configuration object.
12
- def self.build_router(resolved, config)
13
- openapi_version = (resolved['openapi'] || resolved['swagger'])[0..2]
14
- new(resolved, config, openapi_version).router
16
+ def self.build_router(contents, filepath:, config:)
17
+ new(contents, filepath:, config:).router
15
18
  end
16
19
 
17
- def initialize(resolved, config, openapi_version)
18
- @resolved = resolved
20
+ def initialize(contents, filepath:, config:)
21
+ @schemer_configuration = JSONSchemer::Configuration.new(
22
+ meta_schema: detect_meta_schema(contents, filepath),
23
+ insert_property_defaults: true
24
+ )
19
25
  @config = config
20
- @openapi_version = openapi_version
26
+ @contents = RefResolver.for(contents, dir: filepath && File.dirname(filepath))
21
27
  end
22
28
 
23
- attr_reader :resolved, :openapi_version, :config
29
+ attr_reader :config
30
+ private attr_reader :schemer_configuration
31
+
32
+ def detect_meta_schema(document, filepath)
33
+ # Copied from JSONSchemer 🙇🏻‍♂️
34
+ version = document['openapi']
35
+ case version
36
+ when /\A3\.1\.\d+\z/
37
+ @document_schema = JSONSchemer.openapi31_document
38
+ document.fetch('jsonSchemaDialect') { JSONSchemer::OpenAPI31::BASE_URI.to_s }
39
+ when /\A3\.0\.\d+\z/
40
+ @document_schema = JSONSchemer.openapi30_document
41
+ JSONSchemer::OpenAPI30::BASE_URI.to_s
42
+ else
43
+ raise Error, "Unsupported OpenAPI version #{version.inspect} #{filepath}"
44
+ end
45
+ end
24
46
 
25
47
  def router # rubocop:disable Metrics/MethodLength
26
48
  router = OpenapiFirst::Router.new
27
- resolved['paths'].each do |path, path_item_object|
28
- path_item_object.slice(*REQUEST_METHODS).keys.map do |request_method|
49
+ @contents.fetch('paths').each do |path, path_item_object|
50
+ path_item_object.resolved.keys.intersection(REQUEST_METHODS).map do |request_method|
29
51
  operation_object = path_item_object[request_method]
30
- build_requests(path, request_method, operation_object, path_item_object).each do |request|
52
+ parameters = operation_object['parameters']&.resolved.to_a.chain(path_item_object['parameters']&.resolved.to_a)
53
+ parameters = parse_parameters(parameters)
54
+
55
+ build_requests(path:, request_method:, operation_object:,
56
+ parameters:).each do |request|
31
57
  router.add_request(
32
58
  request,
33
59
  request_method:,
@@ -35,7 +61,7 @@ module OpenapiFirst
35
61
  content_type: request.content_type
36
62
  )
37
63
  end
38
- build_responses(operation_object).each do |response|
64
+ build_responses(responses: operation_object['responses']).each do |response|
39
65
  router.add_response(
40
66
  response,
41
67
  request_method:,
@@ -49,33 +75,120 @@ module OpenapiFirst
49
75
  router
50
76
  end
51
77
 
52
- def build_requests(path, request_method, operation_object, path_item_object)
53
- hooks = config.hooks
54
- path_item_parameters = path_item_object['parameters']
55
- parameters = operation_object['parameters'].to_a.chain(path_item_parameters.to_a)
56
- required_body = operation_object.dig('requestBody', 'required') == true
57
- result = operation_object.dig('requestBody', 'content')&.map do |content_type, content|
58
- Request.new(path:, request_method:, operation_object:, parameters:, content_type:,
59
- content_schema: content['schema'], required_body:, hooks:, openapi_version:)
78
+ def parse_parameters(parameters)
79
+ grouped_parameters = group_parameters(parameters)
80
+ ParsedParameters.new(
81
+ query: grouped_parameters[:query],
82
+ path: grouped_parameters[:path],
83
+ cookie: grouped_parameters[:cookie],
84
+ header: grouped_parameters[:header],
85
+ query_schema: build_parameter_schema(grouped_parameters[:query]),
86
+ path_schema: build_parameter_schema(grouped_parameters[:path]),
87
+ cookie_schema: build_parameter_schema(grouped_parameters[:cookie]),
88
+ header_schema: build_parameter_schema(grouped_parameters[:header])
89
+ )
90
+ end
91
+
92
+ def build_parameter_schema(parameters)
93
+ schema = build_parameters_schema(parameters)
94
+
95
+ JSONSchemer.schema(schema,
96
+ configuration: schemer_configuration,
97
+ after_property_validation: config.hooks[:after_request_parameter_property_validation])
98
+ end
99
+
100
+ def build_requests(path:, request_method:, operation_object:, parameters:)
101
+ required_body = operation_object['requestBody']&.resolved&.fetch('required', false) == true
102
+ result = operation_object.dig('requestBody', 'content')&.map do |content_type, content_object|
103
+ content_schema = content_object['schema'].schema(
104
+ configuration: schemer_configuration,
105
+ after_property_validation: config.hooks[:after_request_body_property_validation]
106
+ )
107
+ Request.new(path:, request_method:,
108
+ operation_object: operation_object.resolved,
109
+ parameters:, content_type:,
110
+ content_schema:,
111
+ required_body:)
60
112
  end || []
61
113
  return result if required_body
62
114
 
63
115
  result << Request.new(
64
- path:, request_method:, operation_object:,
116
+ path:, request_method:, operation_object: operation_object.resolved,
65
117
  parameters:, content_type: nil, content_schema: nil,
66
- required_body:, hooks:, openapi_version:
118
+ required_body:
67
119
  )
68
120
  end
69
121
 
70
- def build_responses(operation_object)
71
- Array(operation_object['responses']).flat_map do |status, response_object|
72
- headers = response_object['headers']
122
+ def build_responses(responses:)
123
+ return [] unless responses
124
+
125
+ responses.flat_map do |status, response_object|
126
+ headers = response_object['headers']&.resolved
127
+ headers_schema = JSONSchemer::Schema.new(
128
+ build_headers_schema(headers),
129
+ configuration: schemer_configuration
130
+ )
73
131
  response_object['content']&.map do |content_type, content_object|
74
- content_schema = content_object['schema']
75
- Response.new(status:, headers:, content_type:, content_schema:, openapi_version:)
76
- end || Response.new(status:, headers:, content_type: nil,
77
- content_schema: nil, openapi_version:)
132
+ content_schema = content_object['schema'].schema(configuration: schemer_configuration)
133
+ Response.new(status:,
134
+ headers:,
135
+ headers_schema:,
136
+ content_type:,
137
+ content_schema:)
138
+ end || Response.new(status:, headers:, headers_schema:, content_type: nil,
139
+ content_schema: nil)
140
+ end
141
+ end
142
+
143
+ IGNORED_HEADER_PARAMETERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
144
+ private_constant :IGNORED_HEADER_PARAMETERS
145
+
146
+ def group_parameters(parameter_definitions)
147
+ result = {}
148
+ parameter_definitions&.each do |parameter|
149
+ (result[parameter['in'].to_sym] ||= []) << parameter
150
+ end
151
+ result[:header]&.reject! { IGNORED_HEADER_PARAMETERS.include?(_1['name']) }
152
+ result
153
+ end
154
+
155
+ def build_headers_schema(headers_object)
156
+ return unless headers_object&.any?
157
+
158
+ properties = {}
159
+ required = []
160
+ headers_object.each do |name, header|
161
+ schema = header['schema']
162
+ next if name.casecmp('content-type').zero?
163
+
164
+ properties[name] = schema if schema
165
+ required << name if header['required']
78
166
  end
167
+ {
168
+ 'properties' => properties,
169
+ 'required' => required
170
+ }
79
171
  end
172
+
173
+ def build_parameters_schema(parameters)
174
+ return unless parameters
175
+
176
+ properties = {}
177
+ required = []
178
+ parameters.each do |parameter|
179
+ schema = parameter['schema']
180
+ name = parameter['name']
181
+ properties[name] = schema if schema
182
+ required << name if parameter['required']
183
+ end
184
+
185
+ {
186
+ 'properties' => properties,
187
+ 'required' => required
188
+ }
189
+ end
190
+
191
+ ParsedParameters = Data.define(:path, :query, :header, :cookie, :path_schema, :query_schema, :header_schema,
192
+ :cookie_schema)
80
193
  end
81
194
  end
@@ -22,16 +22,16 @@ module OpenapiFirst
22
22
  # @return [Router]
23
23
  attr_reader :router
24
24
 
25
- # @param resolved [Hash] The resolved OpenAPI document.
25
+ # @param contents [Hash] The OpenAPI document.
26
26
  # @param filepath [String] The file path of the OpenAPI document.
27
- def initialize(resolved, filepath = nil)
27
+ def initialize(contents, filepath = nil)
28
28
  @filepath = filepath
29
29
  @config = OpenapiFirst.configuration.clone
30
30
  yield @config if block_given?
31
31
  @config.freeze
32
- @router = Builder.build_router(resolved, @config)
33
- @resolved = resolved
34
- @paths = resolved['paths'].keys # TODO: Move into builder as well
32
+ @router = Builder.build_router(contents, filepath:, config:)
33
+ @resolved = contents
34
+ @paths = @router.routes.map(&:path).to_a.uniq # TODO: Refactor
35
35
  end
36
36
 
37
37
  # Gives access to the raw resolved Hash. Like `mydefinition['components'].dig('schemas', 'Stations')`
@@ -26,7 +26,7 @@ module OpenapiFirst
26
26
  status:
27
27
  }
28
28
  result[:errors] = errors if failure.errors
29
- MultiJson.dump(result)
29
+ JSON.generate(result)
30
30
  end
31
31
 
32
32
  def type = failure.type
@@ -8,7 +8,7 @@ module OpenapiFirst
8
8
  OpenapiFirst.register_error_response(:jsonapi, self)
9
9
 
10
10
  def body
11
- MultiJson.dump({ errors: serialized_errors })
11
+ JSON.generate({ errors: serialized_errors })
12
12
  end
13
13
 
14
14
  def content_type
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module OpenapiFirst
6
+ # @!visibility private
7
+ module FileLoader
8
+ module_function
9
+
10
+ def load(file_path)
11
+ raise FileNotFoundError, "File not found #{file_path}" unless File.exist?(file_path)
12
+
13
+ body = File.read(file_path)
14
+ extname = File.extname(file_path)
15
+ return JSON.parse(body) if extname == '.json'
16
+ return YAML.unsafe_load(body) if ['.yaml', '.yml'].include?(extname)
17
+
18
+ body
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This tries to load MultiJson if available to keep compatibility
4
+ # with MultiJson until next major version
5
+
6
+ begin
7
+ require 'multi_json'
8
+ module OpenapiFirst
9
+ # Compatibility with MultiJson
10
+ # @visibility private
11
+ module JSON
12
+ ParserError = MultiJson::ParseError
13
+
14
+ def self.parse(string)
15
+ MultiJson.load(string)
16
+ end
17
+
18
+ def self.generate(object)
19
+ MultiJson.dump(object)
20
+ end
21
+ end
22
+ end
23
+ rescue LoadError
24
+ require 'json'
25
+ puts 'openapi_first uses the default json gem'
26
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ # Functions to handle JSON Pointers
5
+ # @!visibility private
6
+ module JsonPointer
7
+ ESCAPE_CHARS = { '~' => '~0', '/' => '~1', '+' => '%2B' }.freeze
8
+ ESCAPE_REGEX = Regexp.union(ESCAPE_CHARS.keys)
9
+
10
+ module_function
11
+
12
+ def append(root, *tokens)
13
+ "#{root}/" + tokens.map do |token|
14
+ escape_json_pointer_token(token)
15
+ end.join('/')
16
+ end
17
+
18
+ def escape_json_pointer_token(token)
19
+ token.to_s.gsub(ESCAPE_REGEX, ESCAPE_CHARS)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json_schemer'
4
+
5
+ module OpenapiFirst
6
+ # This is here to give traverse an OAD while keeping $refs intact
7
+ # @visibility private
8
+ module RefResolver
9
+ def self.load(file_path)
10
+ contents = OpenapiFirst::FileLoader.load(file_path)
11
+ self.for(contents, dir: File.dirname(File.expand_path(file_path)))
12
+ end
13
+
14
+ def self.for(value, context: value, dir: Dir.pwd)
15
+ case value
16
+ when ::Hash
17
+ Hash.new(value, context:, dir:)
18
+ when ::Array
19
+ Array.new(value, context:, dir:)
20
+ when ::NilClass
21
+ nil
22
+ else
23
+ Simple.new(value)
24
+ end
25
+ end
26
+
27
+ # @visibility private
28
+ module Diggable
29
+ def dig(*keys)
30
+ keys.inject(self) do |result, key|
31
+ break unless result.respond_to?(:[])
32
+
33
+ result[key]
34
+ end
35
+ end
36
+ end
37
+
38
+ # @visibility private
39
+ module Resolvable
40
+ def initialize(value, context: value, dir: nil)
41
+ @value = value
42
+ @context = context
43
+ @dir = dir
44
+ end
45
+
46
+ # The value of this node
47
+ attr_reader :value
48
+ # The path of the file sytem directory where this was loaded from
49
+ attr_reader :dir
50
+ # The object where this node was found in
51
+ attr_reader :context
52
+
53
+ def resolve_ref(pointer)
54
+ if pointer.start_with?('#')
55
+ value = Hana::Pointer.new(pointer[1..]).eval(context)
56
+ raise "Unknown reference #{pointer} in #{context}" unless value
57
+
58
+ return RefResolver.for(value, dir:)
59
+ end
60
+
61
+ relative_path, file_pointer = pointer.split('#')
62
+ full_path = File.expand_path(relative_path, dir)
63
+ return RefResolver.load(full_path) unless file_pointer
64
+
65
+ file_contents = FileLoader.load(full_path)
66
+ new_dir = File.dirname(full_path)
67
+ value = Hana::Pointer.new(file_pointer).eval(file_contents)
68
+ RefResolver.for(value, dir: new_dir)
69
+ end
70
+ end
71
+
72
+ # @visibility private
73
+ class Simple
74
+ include Resolvable
75
+
76
+ def resolved = value
77
+ end
78
+
79
+ # @visibility private
80
+ class Hash
81
+ include Resolvable
82
+ include Diggable
83
+ include Enumerable
84
+
85
+ def resolved
86
+ return resolve_ref(value['$ref']).value if value.key?('$ref')
87
+
88
+ value
89
+ end
90
+
91
+ def [](key)
92
+ return resolve_ref(@value['$ref'])[key] if !@value.key?(key) && @value.key?('$ref')
93
+
94
+ RefResolver.for(@value[key], dir:, context:)
95
+ end
96
+
97
+ def fetch(key)
98
+ return resolve_ref(@value['$ref']).fetch(key) if !@value.key?(key) && @value.key?('$ref')
99
+
100
+ RefResolver.for(@value.fetch(key), dir:, context:)
101
+ end
102
+
103
+ def each
104
+ resolved.each do |key, value|
105
+ yield key, RefResolver.for(value, dir:, context:)
106
+ end
107
+ end
108
+
109
+ def schema(options = {})
110
+ ref_resolver = JSONSchemer::CachedResolver.new do |uri|
111
+ FileLoader.load(File.join(dir, uri.path))
112
+ end
113
+ root = JSONSchemer::Schema.new(context, ref_resolver:, **options)
114
+ JSONSchemer::Schema.new(value, nil, root, **options)
115
+ end
116
+ end
117
+
118
+ # @visibility private
119
+ class Array
120
+ include Resolvable
121
+ include Diggable
122
+
123
+ def [](index)
124
+ item = @value[index]
125
+ return resolve_ref(item['$ref']) if item.is_a?(::Hash) && item.key?('$ref')
126
+
127
+ RefResolver.for(item, dir:, context:)
128
+ end
129
+
130
+ def resolved
131
+ value.map do |item|
132
+ if item.respond_to?(:key?) && item.key?('$ref')
133
+ resolve_ref(item['$ref']).resolved
134
+ else
135
+ item
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end