openapi_first 3.3.1 → 3.4.1

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +1 -0
  4. data/lib/openapi_first/builder.rb +5 -2
  5. data/lib/openapi_first/configuration.rb +6 -0
  6. data/lib/openapi_first/definition.rb +43 -9
  7. data/lib/openapi_first/middlewares/request_validation.rb +21 -2
  8. data/lib/openapi_first/middlewares/response_validation.rb +2 -1
  9. data/lib/openapi_first/plugins/x_public.rb +29 -0
  10. data/lib/openapi_first/plugins.rb +44 -0
  11. data/lib/openapi_first/registry.rb +2 -2
  12. data/lib/openapi_first/request.rb +28 -15
  13. data/lib/openapi_first/request_body_parsers.rb +40 -8
  14. data/lib/openapi_first/request_validator.rb +5 -1
  15. data/lib/openapi_first/response.rb +2 -12
  16. data/lib/openapi_first/response_body_parsers.rb +2 -2
  17. data/lib/openapi_first/response_parser.rb +6 -3
  18. data/lib/openapi_first/response_validator.rb +4 -3
  19. data/lib/openapi_first/schema/hash.rb +1 -1
  20. data/lib/openapi_first/test/configuration.rb +45 -4
  21. data/lib/openapi_first/test/coverage/html_reporter/context.rb +89 -0
  22. data/lib/openapi_first/test/coverage/html_reporter.css +179 -0
  23. data/lib/openapi_first/test/coverage/html_reporter.html.erb +87 -0
  24. data/lib/openapi_first/test/coverage/html_reporter.rb +30 -0
  25. data/lib/openapi_first/test/coverage/plan.rb +4 -3
  26. data/lib/openapi_first/test/coverage/route_task.rb +5 -0
  27. data/lib/openapi_first/test/coverage/{terminal_formatter.rb → terminal_reporter.rb} +25 -33
  28. data/lib/openapi_first/test/coverage.rb +15 -7
  29. data/lib/openapi_first/test/logger.rb +17 -0
  30. data/lib/openapi_first/test/observe.rb +1 -1
  31. data/lib/openapi_first/test.rb +16 -6
  32. data/lib/openapi_first/validators/request_body.rb +3 -2
  33. data/lib/openapi_first/validators/request_parameters.rb +4 -4
  34. data/lib/openapi_first/validators/response_body.rb +2 -2
  35. data/lib/openapi_first/validators/response_headers.rb +4 -3
  36. data/lib/openapi_first/version.rb +1 -1
  37. data/lib/openapi_first.rb +8 -0
  38. metadata +11 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1786c083ff520504c716e9bdd1e8b92754be71e70dd774644b6a0c655681369a
4
- data.tar.gz: bc86929efbbe041515cfb12e6d453133cb4cf0a849c40ae5c526eda1b910a4cd
3
+ metadata.gz: 25a078c6bc5ab1617e9dd79545146b3ef7a0f28dbf5d9550819907e8e36c5b1b
4
+ data.tar.gz: 4478e189440cf7915416eb10b61b8a62e7a7465ffd62d64bf9663b2fab1fafde
5
5
  SHA512:
6
- metadata.gz: 4b745143e975173192680152d5b42243c4eb58e8158b4f71aa67730780ad9ec49e788767f8bd5816f0b5dc6ffb711bbba362968a2c075db11dc5c7f11d29733d
7
- data.tar.gz: 32b7bbab75bd6fbb1620808f389d4bfc051a20da124823ada6ea154635306b8b6f6ad378c4bc55a1733211b6438092b307d36d83cc3d23494462729c7b80c817
6
+ metadata.gz: ebee301cf2048cabd799f604073b34691434a55ee2f0787428588c33c4bc9ee49d551e7eee761641bfc61ac724e68f4e7774e6c25eb073aab6bdbcbf2ddd4f10
7
+ data.tar.gz: 53b1e8f5776ba5dfd41be46f9651e76e64a54ae849984a1b93980c7ab9da3353140fefb8ed4399679098f14d631d17ff30ab66cb7f4f9c7315308018aa727533
data/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 3.4.1
6
+
7
+ Fixed: Added missing ERB and css file to the gem
8
+
9
+ ## 3.4.0
10
+
11
+ ### Changed
12
+ - Use a new coverage reporter `OpenapiFirst::Test::Coverage::HtmlReporter` by default that writes a self-contained HTML coverage report to `coverage/openapi_coverage.html`.
13
+
14
+ ### Added
15
+ - Apps using request validation middleware can call `OpenapiFirst::Failure.fail!` to produce an error result
16
+ - The `after_request_validation` now supports throwing a Failure (via `OpenapiFirst::Failure.fail!`), which will result in a failed request
17
+ - Added new hook `before_request_validation`. Called after a request routed to an operation but before the request is validated. You can throw via `Failure.fail!` to abort request validation immediately.
18
+ - `OpenapiFirst::Test.logger` is now configurable via the setup block: `OpenapiFirst::Test.setup { |test| test.logger = Logger.new($stderr) }`. The logger defaults to `Logger.new($stdout)`.
19
+ - Support `encoding[<field>].contentType` on `multipart/form-data` request bodies. Fields whose encoding declares a JSON content type are JSON-parsed before schema validation. Fixes [#398](https://github.com/ahx/openapi_first/issues/398).
20
+ - Start introducing a plugin system modeled after Sequels plugin system.
21
+ - Add an example plugin: 'x_public' (`OpenapiFirst.plugin :x_public`) that returns 404 unless the operatio has `x-public: true`
22
+ - Update openapi_parameters dependency to support `content` field in parameter. Solves [#476](https://github.com/ahx/openapi_first/issues/476)
23
+
24
+ ### Deprecated
25
+ - Deprecated `OpenapiFirst::Test::Coverage::TerminalFormatter`. Use `TerminalReporter` instead
26
+ - Deprecated and `Test::Configuration#coverage_formatter_options`. Use `#coverage_reporter_options` instead.
27
+ - Deprecated `TerminalFormatter#format`. Use `#report` instead.
28
+
5
29
  ## 3.3.1
6
30
 
7
31
  - Optimized caching towards reducing retained memory after calling `OpenapiFirst.load` without using a global cache. (Removed `OpenapiFirst.clear_cache!`.)
data/README.md CHANGED
@@ -378,6 +378,7 @@ You can integrate your code at certain points during request/response validation
378
378
 
379
379
  Available hooks:
380
380
 
381
+ - `before_request_validation`
381
382
  - `after_request_validation`
382
383
  - `after_response_validation`
383
384
  - `after_request_parameter_property_validation`
@@ -13,7 +13,7 @@ require_relative 'ref_resolver'
13
13
  module OpenapiFirst
14
14
  # Builds parts of a Definition
15
15
  # This knows how to read a resolved OpenAPI document and build {Request} and {Response} objects.
16
- class Builder # rubocop:disable Metrics/ClassLength
16
+ class Builder
17
17
  REQUEST_METHODS = %w[get head post put patch delete trace options query].freeze
18
18
 
19
19
  # Builds a router from a resolved OpenAPI document.
@@ -130,7 +130,7 @@ module OpenapiFirst
130
130
  after_property_validation: config.after_request_parameter_property_validation)
131
131
  end
132
132
 
133
- def build_requests(path:, request_method:, operation_object:, parameters:)
133
+ def build_requests(path:, request_method:, operation_object:, parameters:) # rubocop:disable Metrics/MethodLength
134
134
  content_objects = operation_object.dig('requestBody', 'content')
135
135
  if content_objects.nil?
136
136
  return [
@@ -143,10 +143,12 @@ module OpenapiFirst
143
143
  configuration: schemer_configuration,
144
144
  after_property_validation: config.after_request_body_property_validation
145
145
  )
146
+ encoding = content_object['encoding']&.resolved
146
147
  Request.new(path:, request_method:, parameters:,
147
148
  operation_object: operation_object.resolved,
148
149
  content_type:,
149
150
  content_schema:,
151
+ encoding:,
150
152
  required_body:,
151
153
  key: [path, request_method, content_type].join(':'))
152
154
  end
@@ -157,6 +159,7 @@ module OpenapiFirst
157
159
  operation_object: operation_object.resolved,
158
160
  content_type: nil,
159
161
  content_schema: nil,
162
+ encoding: nil,
160
163
  required_body: false,
161
164
  key: [path, request_method, nil].join(':'))
162
165
  end
@@ -4,6 +4,7 @@ module OpenapiFirst
4
4
  # Global configuration. Currently only used for the request validation middleware.
5
5
  class Configuration
6
6
  HOOKS = %i[
7
+ before_request_validation
7
8
  after_request_validation
8
9
  after_response_validation
9
10
  after_request_parameter_property_validation
@@ -66,6 +67,11 @@ module OpenapiFirst
66
67
  end
67
68
  end
68
69
 
70
+ def plugin(name, **)
71
+ require_relative 'plugins'
72
+ Plugins.load(name).configure(self, **)
73
+ end
74
+
69
75
  def request_validation_error_response=(mod)
70
76
  @request_validation_error_response = if mod.is_a?(Symbol)
71
77
  OpenapiFirst.find_error_response(mod)
@@ -44,6 +44,11 @@ module OpenapiFirst
44
44
  # @return [Enumerable[Router::Route]]
45
45
  def_delegators :@router, :routes
46
46
 
47
+ # @return [String,nil] The title from the OpenAPI document's `info.title`, if any.
48
+ def title
49
+ self['info']&.[]('title')
50
+ end
51
+
47
52
  # Returns a unique identifier for this API definition
48
53
  # @return [String] A unique key for this API definition
49
54
  def key
@@ -69,17 +74,23 @@ module OpenapiFirst
69
74
  # Validates the request against the API description.
70
75
  # @param [Rack::Request] request The Rack request object.
71
76
  # @param [Boolean] raise_error Whether to raise an error if validation fails.
77
+ # @yield [ValidatedRequest] Optional block called after successful validation.
78
+ # The block runs inside the same catch(FAILURE) as the after_request_validation hooks,
79
+ # so it may call OpenapiFirst::Failure.fail! to short-circuit and produce an error.
72
80
  # @return [ValidatedRequest] The validated request object.
73
- def validate_request(request, raise_error: false)
81
+ def validate_request(request, raise_error: false, &after_block)
74
82
  route = @router.match(request.request_method, resolve_path(request), content_type: request.content_type)
75
- if route.error
76
- ValidatedRequest.new(request, error: route.error)
77
- else
78
- route.request_definition.validate(request, route_params: route.params)
79
- end.tap do |validated|
80
- @config.after_request_validation.each { |hook| hook.call(validated, self) }
81
- raise validated.error.exception(validated) if validated.error && raise_error
82
- end
83
+ validated = if route.error
84
+ ValidatedRequest.new(request, error: route.error)
85
+ else
86
+ result = call_before_request_validation_hooks(request, route.request_definition)
87
+ result ||= route.request_definition.validate(request, route_params: route.params)
88
+ result.is_a?(Failure) ? ValidatedRequest.new(request, error: result) : result
89
+ end
90
+ validated = call_after_request_validation_hooks(request, validated, &after_block)
91
+ raise validated.error.exception(validated) if validated.error && raise_error
92
+
93
+ validated
83
94
  end
84
95
 
85
96
  # Validates the response against the API description.
@@ -106,6 +117,29 @@ module OpenapiFirst
106
117
 
107
118
  private
108
119
 
120
+ def call_before_request_validation_hooks(request, request_definition)
121
+ return if @config.before_request_validation.none?
122
+
123
+ catch(FAILURE) do
124
+ @config.before_request_validation.each do |hook|
125
+ hook.call(request, request_definition, self)
126
+ end
127
+ nil
128
+ end
129
+ end
130
+
131
+ def call_after_request_validation_hooks(request, validated)
132
+ hooks = @config.after_request_validation
133
+ return validated if hooks.none? && !block_given?
134
+
135
+ error = catch(FAILURE) do
136
+ hooks.each { |hook| hook.call(validated, self) }
137
+ yield validated if block_given? && validated.valid?
138
+ return validated
139
+ end
140
+ ValidatedRequest.new(request, error: error)
141
+ end
142
+
109
143
  def resolve_path(rack_request)
110
144
  return rack_request.path.delete_prefix(path_prefix) if path_prefix && rack_request.path.start_with?(path_prefix)
111
145
  return rack_request.path unless @config.path
@@ -3,7 +3,19 @@
3
3
  require 'rack'
4
4
  module OpenapiFirst
5
5
  module Middlewares
6
- # A Rack middleware to validate requests against an OpenAPI API description
6
+ # A Rack middleware to validate requests against an OpenAPI API description.
7
+ # All error responses can be customized via the +error_response:+ option.
8
+ # === Request body validation
9
+ #
10
+ # * Returns +415+ if the request content-type does not match the OpenAPI description.
11
+ # * Returns +400+ for invalid JSON, missing required fields, type/enum/schema violations, or +readOnly+ fields.
12
+ # * Empty bodies are accepted when the body is optional in the spec.
13
+ #
14
+ # === Parameter validation
15
+ #
16
+ # Query, path, header, and cookie parameters are validated and type-converted against the spec.
17
+ # Missing required parameters or type/format violations return +400+.
18
+ #
7
19
  class RequestValidation
8
20
  # @param app The parent Rack application
9
21
  # @param spec [String, Symbol, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
@@ -37,10 +49,17 @@ module OpenapiFirst
37
49
  attr_reader :app
38
50
 
39
51
  def call(env)
40
- validated = @definition.validate_request(Rack::Request.new(env), raise_error: @raise)
52
+ rack_response = nil
53
+ app_called = false
54
+ validated = @definition.validate_request(Rack::Request.new(env), raise_error: @raise) do |v|
55
+ app_called = true
56
+ env[REQUEST] = v
57
+ rack_response = @app.call(env)
58
+ end
41
59
  env[REQUEST] = validated
42
60
  failure = validated.error
43
61
  return @error_response_class.new(failure:).render if failure && @error_response_class
62
+ return rack_response if app_called
44
63
 
45
64
  @app.call(env)
46
65
  end
@@ -4,7 +4,8 @@ require 'rack'
4
4
 
5
5
  module OpenapiFirst
6
6
  module Middlewares
7
- # A Rack middleware to validate requests against an OpenAPI API description
7
+ # A Rack middleware to validate requests against an OpenAPI API description.
8
+ # You probably want to use +OpenapiFirst::Test+ instead of this middleware.
8
9
  class ResponseValidation
9
10
  # @param spec [String, Symbol, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
10
11
  # If you pass a Symbol, it will load the OAD registered via `OpenapiFirst.register`
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module Plugins
5
+ # Enforces that only operations explicitly marked as public are accessible.
6
+ # Throws a :not_found failure for any matched operation that lacks the configured field.
7
+ #
8
+ # Options:
9
+ # field: [String] The OpenAPI extension field to check. Default: 'x-public'.
10
+ # if: [Proc] Optional condition proc. Receives the Rack::Request and must
11
+ # return truthy for the check to apply. Use this to restrict the
12
+ # plugin to certain hosts or path prefixes.
13
+ #
14
+ # Example:
15
+ # OpenapiFirst.plugin :x_public
16
+ # OpenapiFirst.plugin :x_public, field: 'x-visible'
17
+ # OpenapiFirst.plugin :x_public, if: ->(req) { req.host == 'api.example.com' }
18
+ module XPublic
19
+ def self.configure(config, field: 'x-public', **opts)
20
+ condition = opts[:if]
21
+ config.before_request_validation do |request, request_definition|
22
+ next if condition && !condition.call(request)
23
+
24
+ Failure.fail!(:not_found) unless request_definition.operation[field]
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ # Plugin system for extending openapi_first behaviour. Modeled after Sequels plugin system.
5
+ #
6
+ # A plugin is a module under the +OpenapiFirst::Plugins+ namespace with a
7
+ # +.configure(config, **opts)+ class method. Plugins wire themselves in by
8
+ # registering hooks on the +Configuration+ object they receive.
9
+ #
10
+ # Loading a plugin:
11
+ #
12
+ # # Globally (applies to all definitions):
13
+ # OpenapiFirst.plugin :x_public
14
+ #
15
+ # # Per definition:
16
+ # OpenapiFirst.load('openapi.yaml') { |c| c.plugin :x_public, field: 'x-visible' }
17
+ #
18
+ # Writing a plugin:
19
+ #
20
+ # module OpenapiFirst::Plugins::MyPlugin
21
+ # def self.configure(config, **)
22
+ # config.after_request_validation do |validated_request|
23
+ # # ...
24
+ # end
25
+ # end
26
+ # end
27
+ #
28
+ # Third-party plugins are discovered by placing a module at the expected
29
+ # constant path and/or providing a file at <tt>openapi_first/plugins/<name></tt>
30
+ # on the load path.
31
+ module Plugins
32
+ def self.load(name)
33
+ module_name = name.to_s.split('_').map(&:capitalize).join
34
+ mod = const_get(module_name) if const_defined?(module_name, false)
35
+ mod ||= begin
36
+ require "openapi_first/plugins/#{name}"
37
+ const_get(module_name)
38
+ end
39
+ raise ArgumentError, "Plugin #{name.inspect} must respond to .configure" unless mod.respond_to?(:configure)
40
+
41
+ mod
42
+ end
43
+ end
44
+ end
@@ -13,7 +13,7 @@ module OpenapiFirst
13
13
  # Register an OpenAPI definition for testing
14
14
  # @param path_or_definition [String, Definition] Path to the OpenAPI file or a Definition object
15
15
  # @param as [Symbol] Name to register the API definition as
16
- def register(path_or_definition, as: :default)
16
+ def register(path_or_definition, as: :default, &)
17
17
  if definitions.key?(as) && as == :default
18
18
  raise(
19
19
  AlreadyRegisteredError,
@@ -24,7 +24,7 @@ module OpenapiFirst
24
24
  )
25
25
  end
26
26
 
27
- definition = OpenapiFirst.load(path_or_definition)
27
+ definition = OpenapiFirst.load(path_or_definition, &)
28
28
  definitions[as] = definition
29
29
  definition
30
30
  end
@@ -12,8 +12,8 @@ module OpenapiFirst
12
12
  # An 3.x Operation object can accept multiple requests, because it can handle multiple content-types.
13
13
  # This class represents one of those requests.
14
14
  class Request
15
- def initialize(path:, request_method:, operation_object:, # rubocop:disable Metrics/MethodLength
16
- parameters:, content_type:, content_schema:, required_body:, key:)
15
+ def initialize(path:, request_method:, operation_object:, # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists
16
+ parameters:, content_type:, content_schema:, required_body:, key:, encoding: nil)
17
17
  @path = path
18
18
  @request_method = request_method
19
19
  @content_type = content_type
@@ -25,7 +25,7 @@ module OpenapiFirst
25
25
  @path_parser = parameters.path&.then { |params| OpenapiParameters::Path.new(params) }
26
26
  @headers_parser = parameters.header&.then { |params| OpenapiParameters::Header.new(params) }
27
27
  @cookies_parser = parameters.cookie&.then { |params| OpenapiParameters::Cookie.new(params) }
28
- @body_parsers = RequestBodyParsers[content_type] if content_type
28
+ @body_parsers = build_body_parser(content_type, encoding) if content_type
29
29
  @validator = RequestValidator.new(
30
30
  content_schema:,
31
31
  required_request_body: required_body == true,
@@ -45,12 +45,8 @@ module OpenapiFirst
45
45
  end
46
46
 
47
47
  def validate(request, route_params:)
48
- parsed_request = nil
49
- error = catch FAILURE do
50
- parsed_request = parse_request(request, route_params:)
51
- @validator.call(parsed_request)
52
- nil
53
- end
48
+ parsed_request, error = parse_request(request, route_params:)
49
+ error ||= @validator.call(parsed_request) if parsed_request
54
50
  ValidatedRequest.new(request, parsed_request:, error:, request_definition: self, query_parser:)
55
51
  end
56
52
 
@@ -58,22 +54,39 @@ module OpenapiFirst
58
54
  @operation['operationId']
59
55
  end
60
56
 
57
+ MULTIPART_CONTENT_TYPE = %r{\Amultipart/form-data\b}i
58
+ private_constant :MULTIPART_CONTENT_TYPE
59
+
61
60
  private
62
61
 
63
62
  def parse_request(request, route_params:)
64
- ParsedRequest.new(
63
+ query, query_error = parse_query(request.env[Rack::QUERY_STRING])
64
+ return [nil, query_error] if query_error
65
+
66
+ body = @body_parsers&.call(request)
67
+ return [nil, body] if body.is_a?(Failure)
68
+
69
+ [ParsedRequest.new(
65
70
  path: @path_parser&.unpack(route_params),
66
- query: parse_query(request.env[Rack::QUERY_STRING]),
71
+ query:,
67
72
  headers: @headers_parser&.unpack_env(request.env),
68
73
  cookies: @cookies_parser&.unpack(request.env[Rack::HTTP_COOKIE]),
69
- body: @body_parsers&.call(request)
70
- )
74
+ body:
75
+ ), nil]
71
76
  end
72
77
 
73
78
  def parse_query(query_string)
74
- @query_parser&.unpack(query_string)
79
+ [@query_parser&.unpack(query_string), nil]
75
80
  rescue OpenapiParameters::InvalidParameterError
76
- Failure.fail!(:invalid_query, message: 'Invalid query parameter.')
81
+ [nil, Failure.new(:invalid_query, message: 'Invalid query parameter.')]
82
+ end
83
+
84
+ def build_body_parser(content_type, encoding)
85
+ if content_type.match?(MULTIPART_CONTENT_TYPE)
86
+ RequestBodyParsers::MultipartBodyParser.new(encoding: encoding || {})
87
+ else
88
+ RequestBodyParsers[content_type]
89
+ end
77
90
  end
78
91
  end
79
92
  end
@@ -33,27 +33,59 @@ module OpenapiFirst
33
33
  body = Utils.read_body(request)
34
34
  JSON.parse(body) unless body.nil? || body.empty?
35
35
  rescue JSON::ParserError
36
- Failure.fail!(:invalid_body, message: 'Failed to parse request body as JSON')
36
+ Failure.new(:invalid_body, message: 'Failed to parse request body as JSON')
37
37
  end)
38
38
 
39
39
  # Parses multipart/form-data requests and currently puts the contents of a file upload at the parsed hash values.
40
40
  # NOTE: This behavior will probably change in the next major version.
41
41
  # The uploaded file should not be read during request validation.
42
- module MultipartBodyParser
42
+ #
43
+ # Honors the OpenAPI `encoding` map: when a top-level field has
44
+ # `contentType: application/json` (or any */json), the field's raw value
45
+ # is JSON-parsed before schema validation.
46
+ class MultipartBodyParser
47
+ def initialize(encoding: {})
48
+ @encoding = encoding || {}
49
+ end
50
+
43
51
  def self.call(request)
44
- request.POST.transform_values do |value|
45
- unpack_value(value)
52
+ new.call(request)
53
+ end
54
+
55
+ def call(request)
56
+ result = {}
57
+ request.POST.each do |name, value|
58
+ decoded = decode_field(name, value)
59
+ return decoded if decoded.is_a?(Failure)
60
+
61
+ result[name] = decoded
46
62
  end
63
+ result
64
+ end
65
+
66
+ private
67
+
68
+ def decode_field(name, value)
69
+ raw = unpack_value(value)
70
+ content_type = @encoding.dig(name, 'contentType')
71
+ return raw unless content_type && raw.is_a?(String) && json?(content_type)
72
+
73
+ JSON.parse(raw)
74
+ rescue JSON::ParserError => e
75
+ Failure.new(:invalid_body,
76
+ message: %(Failed to parse multipart field "#{name}" as JSON: #{e.message}))
47
77
  end
48
78
 
49
- def self.unpack_value(value)
79
+ def json?(content_type)
80
+ content_type.match?(%r{[/+]json\b}i)
81
+ end
82
+
83
+ def unpack_value(value)
50
84
  return value.map { unpack_value(_1) } if value.is_a?(Array)
51
85
  return value unless value.is_a?(Hash)
52
86
  return value[:tempfile]&.read if value.key?(:tempfile)
53
87
 
54
- value.transform_values do |v|
55
- unpack_value(v)
56
- end
88
+ value.transform_values { unpack_value(_1) }
57
89
  end
58
90
  end
59
91
 
@@ -26,7 +26,11 @@ module OpenapiFirst
26
26
  end
27
27
 
28
28
  def call(parsed_request)
29
- @validators.each { |v| v.call(parsed_request) }
29
+ @validators.each do |v|
30
+ result = v.call(parsed_request)
31
+ return result if result.is_a?(Failure)
32
+ end
33
+ nil
30
34
  end
31
35
  end
32
36
  end
@@ -25,19 +25,9 @@ module OpenapiFirst
25
25
  attr_reader :status, :content_type, :content_schema, :headers, :key
26
26
 
27
27
  def validate(response)
28
- parsed_values = nil
29
- error = catch FAILURE do
30
- parsed_values = @parser.parse(response)
31
- nil
32
- end
33
- error ||= @validator.call(parsed_values)
28
+ parsed_values, error = @parser.parse(response)
29
+ error ||= @validator.call(parsed_values) if parsed_values
34
30
  ValidatedResponse.new(response, parsed_values:, error:, response_definition: self)
35
31
  end
36
-
37
- private
38
-
39
- def parse(request)
40
- @parser.parse(request)
41
- end
42
32
  end
43
33
  end
@@ -23,9 +23,9 @@ module OpenapiFirst
23
23
  register(/json/i, lambda do |body|
24
24
  JSON.parse(body)
25
25
  rescue JSON::ParserError
26
- return Failure.fail!(:invalid_response_body, message: 'JSON response body must not be empty') if body.empty?
26
+ return Failure.new(:invalid_response_body, message: 'JSON response body must not be empty') if body.empty?
27
27
 
28
- Failure.fail!(:invalid_response_body, message: 'Failed to parse response body as JSON')
28
+ Failure.new(:invalid_response_body, message: 'Failed to parse response body as JSON')
29
29
  end)
30
30
  end
31
31
  end
@@ -13,10 +13,13 @@ module OpenapiFirst
13
13
  end
14
14
 
15
15
  def parse(rack_response)
16
- ParsedResponse.new(
17
- body: @body_parser.call(read_body(rack_response)),
16
+ body = @body_parser.call(read_body(rack_response))
17
+ return [nil, body] if body.is_a?(Failure)
18
+
19
+ [ParsedResponse.new(
20
+ body:,
18
21
  headers: @headers_parser&.call(rack_response.headers) || {}
19
- )
22
+ ), nil]
20
23
  end
21
24
 
22
25
  private
@@ -18,10 +18,11 @@ module OpenapiFirst
18
18
  end
19
19
 
20
20
  def call(parsed_response)
21
- catch FAILURE do
22
- @validators.each { |v| v.call(parsed_response) }
23
- nil
21
+ @validators.each do |v|
22
+ result = v.call(parsed_response)
23
+ return result if result.is_a?(Failure)
24
24
  end
25
+ nil
25
26
  end
26
27
  end
27
28
  end
@@ -6,7 +6,7 @@ module OpenapiFirst
6
6
  module Schema
7
7
  # A hash of Schemas
8
8
  class Hash
9
- # @param schema Hash of schemas
9
+ # @param schemas Hash of schemas
10
10
  # @param required Array of required keys
11
11
  def initialize(schemas, required: nil, **options)
12
12
  @schemas = schemas