openapi_first 1.0.0.beta1 → 1.0.0.beta4

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +8 -20
  3. data/.rubocop.yml +1 -1
  4. data/CHANGELOG.md +23 -0
  5. data/Gemfile +4 -1
  6. data/Gemfile.lock +39 -51
  7. data/README.md +27 -25
  8. data/benchmarks/Gemfile.lock +28 -33
  9. data/benchmarks/apps/openapi_first_with_hanami_api.ru +1 -2
  10. data/benchmarks/apps/openapi_first_with_plain_rack.ru +32 -0
  11. data/benchmarks/apps/openapi_first_with_response_validation.ru +14 -11
  12. data/benchmarks/apps/openapi_first_with_sinatra.ru +29 -0
  13. data/lib/openapi_first/body_parser_middleware.rb +1 -1
  14. data/lib/openapi_first/config.rb +19 -0
  15. data/lib/openapi_first/default_error_response.rb +47 -0
  16. data/lib/openapi_first/definition.rb +8 -1
  17. data/lib/openapi_first/error_response.rb +31 -0
  18. data/lib/openapi_first/errors.rb +3 -40
  19. data/lib/openapi_first/operation.rb +33 -14
  20. data/lib/openapi_first/operation_schemas.rb +52 -0
  21. data/lib/openapi_first/plugins.rb +17 -0
  22. data/lib/openapi_first/request_body_validator.rb +41 -0
  23. data/lib/openapi_first/request_validation.rb +66 -84
  24. data/lib/openapi_first/response_validation.rb +38 -7
  25. data/lib/openapi_first/response_validator.rb +1 -1
  26. data/lib/openapi_first/router.rb +15 -14
  27. data/lib/openapi_first/schema_validation.rb +22 -21
  28. data/lib/openapi_first/string_keyed_hash.rb +20 -0
  29. data/lib/openapi_first/validation_result.rb +15 -0
  30. data/lib/openapi_first/version.rb +1 -1
  31. data/lib/openapi_first.rb +30 -21
  32. data/openapi_first.gemspec +4 -12
  33. metadata +20 -117
  34. data/benchmarks/apps/openapi_first.ru +0 -22
  35. data/lib/openapi_first/utils.rb +0 -35
  36. data/lib/openapi_first/validation_format.rb +0 -55
  37. /data/benchmarks/apps/{committee.ru → committee_with_hanami_api.ru} +0 -0
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ class DefaultErrorResponse < ErrorResponse
5
+ OpenapiFirst::Plugins.register_error_response(:default, self)
6
+
7
+ def body
8
+ MultiJson.dump({ errors: serialized_errors })
9
+ end
10
+
11
+ def serialized_errors
12
+ return default_errors unless validation_output
13
+
14
+ key = pointer_key
15
+ [
16
+ {
17
+ source: { key => pointer(validation_output['instanceLocation']) },
18
+ title: validation_output['error']
19
+ }
20
+ ]
21
+ end
22
+
23
+ def default_errors
24
+ [{
25
+ status: status.to_s,
26
+ title:
27
+ }]
28
+ end
29
+
30
+ def pointer_key
31
+ case location
32
+ when :request_body
33
+ :pointer
34
+ when :query, :path
35
+ :parameter
36
+ else
37
+ location
38
+ end
39
+ end
40
+
41
+ def pointer(data_pointer)
42
+ return data_pointer if location == :request_body
43
+
44
+ data_pointer.delete_prefix('/')
45
+ end
46
+ end
47
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative 'operation'
4
4
 
5
5
  module OpenapiFirst
6
+ # Represents an OpenAPI API Description document
6
7
  class Definition
7
8
  attr_reader :filepath, :operations
8
9
 
@@ -11,9 +12,15 @@ module OpenapiFirst
11
12
  methods = %w[get head post put patch delete trace options]
12
13
  @operations = resolved['paths'].flat_map do |path, path_item|
13
14
  path_item.slice(*methods).map do |request_method, _operation_object|
14
- Operation.new(path, request_method, path_item)
15
+ Operation.new(path, request_method, path_item, openapi_version: detect_version(resolved))
15
16
  end
16
17
  end
17
18
  end
19
+
20
+ private
21
+
22
+ def detect_version(resolved)
23
+ (resolved['openapi'] || resolved['swagger'])[0..2]
24
+ end
18
25
  end
19
26
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ # This is the base class for error responses
5
+ class ErrorResponse
6
+ ## @param status [Integer] The HTTP status code.
7
+ ## @param title [String] The title of the error. Usually the name of the HTTP status code.
8
+ ## @param location [Symbol] The location of the error (:request_body, :query, :header, :cookie, :path).
9
+ ## @param validation_result [ValidationResult]
10
+ def initialize(status:, location:, title:, validation_result:)
11
+ @status = status
12
+ @title = title
13
+ @location = location
14
+ @validation_output = validation_result&.output
15
+ @schema = validation_result&.schema
16
+ @data = validation_result&.data
17
+ end
18
+
19
+ attr_reader :status, :location, :title, :schema, :data, :validation_output
20
+
21
+ def render
22
+ Rack::Response.new(body, status, Rack::CONTENT_TYPE => content_type).finish
23
+ end
24
+
25
+ def content_type = 'application/json'
26
+
27
+ def body
28
+ raise NotImplementedError
29
+ end
30
+ end
31
+ end
@@ -5,15 +5,6 @@ module OpenapiFirst
5
5
 
6
6
  class NotFoundError < Error; end
7
7
 
8
- class HandlerNotFoundError < Error; end
9
-
10
- class NotImplementedError < Error
11
- def initialize(message)
12
- warn 'NotImplementedError is deprecated. Handle HandlerNotFoundError instead'
13
- super
14
- end
15
- end
16
-
17
8
  class ResponseInvalid < Error; end
18
9
 
19
10
  class ResponseCodeNotFoundError < ResponseInvalid; end
@@ -22,37 +13,9 @@ module OpenapiFirst
22
13
 
23
14
  class ResponseBodyInvalidError < ResponseInvalid; end
24
15
 
16
+ class ResponseHeaderInvalidError < ResponseInvalid; end
17
+
25
18
  class BodyParsingError < Error; end
26
19
 
27
- class RequestInvalidError < Error
28
- def initialize(serialized_errors)
29
- message = error_message(serialized_errors)
30
- super message
31
- end
32
-
33
- private
34
-
35
- def error_message(errors)
36
- errors.map do |error|
37
- [human_source(error), human_error(error)].compact.join(' ')
38
- end.join(', ')
39
- end
40
-
41
- def human_source(error)
42
- return unless error[:source]
43
-
44
- source_key = error[:source].keys.first
45
- source = {
46
- pointer: 'Request body invalid:',
47
- parameter: 'Query parameter invalid:'
48
- }.fetch(source_key, source_key)
49
- name = error[:source].values.first
50
- source += " #{name}" unless name.nil? || name.empty?
51
- source
52
- end
53
-
54
- def human_error(error)
55
- error[:title]
56
- end
57
- end
20
+ class RequestInvalidError < Error; end
58
21
  end
@@ -3,10 +3,10 @@
3
3
  require 'forwardable'
4
4
  require 'set'
5
5
  require_relative 'schema_validation'
6
- require_relative 'utils'
6
+ require_relative 'operation_schemas'
7
7
 
8
8
  module OpenapiFirst
9
- class Operation
9
+ class Operation # rubocop:disable Metrics/ClassLength
10
10
  extend Forwardable
11
11
  def_delegators :operation_object,
12
12
  :[],
@@ -15,12 +15,13 @@ module OpenapiFirst
15
15
  WRITE_METHODS = Set.new(%w[post put patch delete]).freeze
16
16
  private_constant :WRITE_METHODS
17
17
 
18
- attr_reader :path, :method
18
+ attr_reader :path, :method, :openapi_version
19
19
 
20
- def initialize(path, request_method, path_item_object)
20
+ def initialize(path, request_method, path_item_object, openapi_version:)
21
21
  @path = path
22
22
  @method = request_method
23
23
  @path_item_object = path_item_object
24
+ @openapi_version = openapi_version
24
25
  end
25
26
 
26
27
  def operation_id
@@ -39,7 +40,7 @@ module OpenapiFirst
39
40
  operation_object['requestBody']
40
41
  end
41
42
 
42
- def response_schema_for(status, content_type)
43
+ def response_body_schema(status, content_type)
43
44
  content = response_for(status)['content']
44
45
  return if content.nil? || content.empty?
45
46
 
@@ -52,16 +53,18 @@ module OpenapiFirst
52
53
  raise ResponseContentTypeNotFoundError, message
53
54
  end
54
55
  schema = media_type['schema']
55
- SchemaValidation.new(schema, write: false) if schema
56
+ return unless schema
57
+
58
+ SchemaValidation.new(schema, write: false, openapi_version:)
56
59
  end
57
60
 
58
61
  def request_body_schema(request_content_type)
59
- content = operation_object.dig('requestBody', 'content')
60
- media_type = find_content_for_content_type(content, request_content_type)
61
- schema = media_type&.fetch('schema', nil)
62
- return unless schema
63
-
64
- SchemaValidation.new(schema, write: write?)
62
+ (@request_body_schema ||= {})[request_content_type] ||= begin
63
+ content = operation_object.dig('requestBody', 'content')
64
+ media_type = find_content_for_content_type(content, request_content_type)
65
+ schema = media_type&.fetch('schema', nil)
66
+ SchemaValidation.new(schema, write: write?, openapi_version:) if schema
67
+ end
65
68
  end
66
69
 
67
70
  def response_for(status)
@@ -73,12 +76,12 @@ module OpenapiFirst
73
76
  end
74
77
 
75
78
  def name
76
- "#{method.upcase} #{path} (#{operation_id})"
79
+ @name ||= "#{method.upcase} #{path} (#{operation_id})"
77
80
  end
78
81
 
79
82
  def valid_request_content_type?(request_content_type)
80
83
  content = operation_object.dig('requestBody', 'content')
81
- return unless content
84
+ return false unless content
82
85
 
83
86
  !!find_content_for_content_type(content, request_content_type)
84
87
  end
@@ -91,6 +94,17 @@ module OpenapiFirst
91
94
  @path_parameters ||= all_parameters.filter { |p| p['in'] == 'path' }
92
95
  end
93
96
 
97
+ IGNORED_HEADERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
98
+ private_constant :IGNORED_HEADERS
99
+
100
+ def header_parameters
101
+ @header_parameters ||= all_parameters.filter { |p| p['in'] == 'header' && !IGNORED_HEADERS.include?(p['name']) }
102
+ end
103
+
104
+ def cookie_parameters
105
+ @cookie_parameters ||= all_parameters.filter { |p| p['in'] == 'cookie' }
106
+ end
107
+
94
108
  def all_parameters
95
109
  @all_parameters ||= begin
96
110
  parameters = @path_item_object['parameters']&.dup || []
@@ -100,6 +114,11 @@ module OpenapiFirst
100
114
  end
101
115
  end
102
116
 
117
+ # visibility: private
118
+ def schemas
119
+ @schemas ||= OperationSchemas.new(self)
120
+ end
121
+
103
122
  private
104
123
 
105
124
  def response_by_code(status)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openapi_parameters/parameter'
4
+ require_relative 'schema_validation'
5
+
6
+ module OpenapiFirst
7
+ # This class is basically a cache for JSON Schemas of parameters
8
+ class OperationSchemas
9
+ # @operation [OpenapiFirst::Operation]
10
+ def initialize(operation)
11
+ @operation = operation
12
+ end
13
+
14
+ attr_reader :operation
15
+
16
+ # Return JSON Schema of for all query parameters
17
+ def query_parameters_schema
18
+ @query_parameters_schema ||= build_json_schema(operation.query_parameters)
19
+ end
20
+
21
+ # Return JSON Schema of for all path parameters
22
+ def path_parameters_schema
23
+ @path_parameters_schema ||= build_json_schema(operation.path_parameters)
24
+ end
25
+
26
+ def header_parameters_schema
27
+ @header_parameters_schema ||= build_json_schema(operation.header_parameters)
28
+ end
29
+
30
+ def cookie_parameters_schema
31
+ @cookie_parameters_schema ||= build_json_schema(operation.cookie_parameters)
32
+ end
33
+
34
+ private
35
+
36
+ # Build JSON Schema for given parameter definitions
37
+ # @parameter_defs [Array<Hash>] Parameter definitions
38
+ def build_json_schema(parameter_defs)
39
+ init_schema = {
40
+ 'type' => 'object',
41
+ 'properties' => {},
42
+ 'required' => []
43
+ }
44
+ schema = parameter_defs.each_with_object(init_schema) do |parameter_def, result|
45
+ parameter = OpenapiParameters::Parameter.new(parameter_def)
46
+ result['properties'][parameter.name] = parameter.schema if parameter.schema
47
+ result['required'] << parameter.name if parameter.required?
48
+ end
49
+ SchemaValidation.new(schema, openapi_version: operation.openapi_version)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module Plugins
5
+ ERROR_RESPONSES = {} # rubocop:disable Style/MutableConstant
6
+
7
+ def self.register_error_response(name, klass)
8
+ ERROR_RESPONSES[name] = klass
9
+ end
10
+
11
+ def self.find_error_response(name)
12
+ return name if name.is_a?(Class)
13
+
14
+ ERROR_RESPONSES.fetch(name)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ class RequestBodyValidator
5
+ def initialize(operation, env)
6
+ @operation = operation
7
+ @env = env
8
+ @parsed_request_body = env[REQUEST_BODY]
9
+ end
10
+
11
+ def validate!
12
+ content_type = Rack::Request.new(@env).content_type
13
+ validate_request_content_type!(@operation, content_type)
14
+ validate_request_body!(@operation, @parsed_request_body, content_type)
15
+ end
16
+
17
+ private
18
+
19
+ def validate_request_content_type!(operation, content_type)
20
+ operation.valid_request_content_type?(content_type) || OpenapiFirst.error!(415)
21
+ end
22
+
23
+ def validate_request_body!(operation, body, content_type)
24
+ validate_request_body_presence!(body, operation)
25
+ return if content_type.nil?
26
+
27
+ schema = operation&.request_body_schema(content_type)
28
+ return unless schema
29
+
30
+ validation_result = schema.validate(body)
31
+ OpenapiFirst.error!(400, :request_body, validation_result:) if validation_result.error?
32
+ body
33
+ end
34
+
35
+ def validate_request_body_presence!(body, operation)
36
+ return unless operation.request_body['required'] && body.nil?
37
+
38
+ OpenapiFirst.error!(400, :request_body, title: 'Request body is required')
39
+ end
40
+ end
41
+ end
@@ -3,7 +3,9 @@
3
3
  require 'rack'
4
4
  require 'multi_json'
5
5
  require_relative 'use_router'
6
- require_relative 'validation_format'
6
+ require_relative 'error_response'
7
+ require_relative 'request_body_validator'
8
+ require_relative 'string_keyed_hash'
7
9
  require 'openapi_parameters'
8
10
 
9
11
  module OpenapiFirst
@@ -13,118 +15,98 @@ module OpenapiFirst
13
15
  def initialize(app, options = {})
14
16
  @app = app
15
17
  @raise = options.fetch(:raise_error, false)
18
+ @error_response_class =
19
+ Plugins.find_error_response(options.fetch(:error_response, Config.default_options.error_response))
16
20
  end
17
21
 
18
- def call(env) # rubocop:disable Metrics/AbcSize
22
+ def call(env)
19
23
  operation = env[OPERATION]
20
24
  return @app.call(env) unless operation
21
25
 
22
- error = catch(:error) do
23
- query_params = OpenapiParameters::Query.new(operation.query_parameters).unpack(env['QUERY_STRING'])
24
- validate_query_parameters!(operation, query_params)
25
- env[PARAMS].merge!(query_params)
26
-
27
- return @app.call(env) unless operation.request_body
28
-
29
- content_type = Rack::Request.new(env).content_type
30
- validate_request_content_type!(operation, content_type)
31
- parsed_request_body = env[REQUEST_BODY]
32
- validate_request_body!(operation, parsed_request_body, content_type)
33
- nil
34
- end
26
+ error = validate_request(operation, env)
35
27
  if error
36
- raise RequestInvalidError, error[:errors] if @raise
28
+ location, title = error.values_at(:location, :title)
29
+ raise RequestInvalidError, error_message(title, location) if @raise
37
30
 
38
- return validation_error_response(error[:status], error[:errors])
31
+ return error_response(error).render
39
32
  end
40
33
  @app.call(env)
41
34
  end
42
35
 
43
36
  private
44
37
 
45
- def validate_request_body!(operation, body, content_type)
46
- validate_request_body_presence!(body, operation)
47
- return if content_type.nil?
38
+ def error_message(title, location)
39
+ return title unless location
48
40
 
49
- schema = operation&.request_body_schema(content_type)
50
- return unless schema
51
-
52
- errors = schema.validate(body)
53
- throw_error(400, serialize_request_body_errors(errors)) if errors.any?
54
- body
55
- end
56
-
57
- def validate_request_content_type!(operation, content_type)
58
- operation.valid_request_content_type?(content_type) || throw_error(415)
41
+ "#{TOPICS.fetch(location)} #{title}"
59
42
  end
60
43
 
61
- def validate_request_body_presence!(body, operation)
62
- return unless operation.request_body['required'] && body.nil?
63
-
64
- throw_error(415, 'Request body is required')
44
+ TOPICS = {
45
+ request_body: 'Request body invalid:',
46
+ query: 'Query parameter invalid:',
47
+ header: 'Header parameter invalid:',
48
+ path: 'Path segment invalid:',
49
+ cookie: 'Cookie value invalid:'
50
+ }.freeze
51
+ private_constant :TOPICS
52
+
53
+ def error_response(error_object)
54
+ @error_response_class.new(**error_object)
65
55
  end
66
56
 
67
- def default_error(status, title = Rack::Utils::HTTP_STATUS_CODES[status])
68
- {
69
- status: status.to_s,
70
- title: title
71
- }
57
+ def validate_request(operation, env)
58
+ catch(:error) do
59
+ env[PARAMS] = {}
60
+ validate_query_params!(operation, env)
61
+ validate_path_params!(operation, env)
62
+ validate_cookie_params!(operation, env)
63
+ validate_header_params!(operation, env)
64
+ RequestBodyValidator.new(operation, env).validate! if operation.request_body
65
+ nil
66
+ end
72
67
  end
73
68
 
74
- def throw_error(status, errors = [default_error(status)])
75
- throw :error, {
76
- status: status,
77
- errors: errors
78
- }
79
- end
69
+ def validate_path_params!(operation, env)
70
+ path_parameters = operation.path_parameters
71
+ return if path_parameters.empty?
80
72
 
81
- def validation_error_response(status, errors)
82
- Rack::Response.new(
83
- MultiJson.dump(errors: errors),
84
- status,
85
- Rack::CONTENT_TYPE => 'application/vnd.api+json'
86
- ).finish
73
+ hashy = StringKeyedHash.new(env[Router::RAW_PATH_PARAMS])
74
+ unpacked_path_params = OpenapiParameters::Path.new(path_parameters).unpack(hashy)
75
+ validation_result = operation.schemas.path_parameters_schema.validate(unpacked_path_params)
76
+ OpenapiFirst.error!(400, :path, validation_result:) if validation_result.error?
77
+ env[PATH_PARAMS] = unpacked_path_params
78
+ env[PARAMS].merge!(unpacked_path_params)
87
79
  end
88
80
 
89
- def serialize_request_body_errors(validation_errors)
90
- validation_errors.map do |error|
91
- {
92
- source: {
93
- pointer: error['data_pointer']
94
- }
95
- }.update(ValidationFormat.error_details(error))
96
- end
97
- end
81
+ def validate_query_params!(operation, env)
82
+ query_parameters = operation.query_parameters
83
+ return if operation.query_parameters.empty?
98
84
 
99
- def build_json_schema(parameter_defs)
100
- init_schema = {
101
- 'type' => 'object',
102
- 'properties' => {},
103
- 'required' => []
104
- }
105
- parameter_defs.each_with_object(init_schema) do |parameter_def, schema|
106
- parameter = OpenapiParameters::Parameter.new(parameter_def)
107
- schema['properties'][parameter.name] = parameter.schema if parameter.schema
108
- schema['required'] << parameter.name if parameter.required?
109
- end
85
+ unpacked_query_params = OpenapiParameters::Query.new(query_parameters).unpack(env['QUERY_STRING'])
86
+ validation_result = operation.schemas.query_parameters_schema.validate(unpacked_query_params)
87
+ OpenapiFirst.error!(400, :query, validation_result:) if validation_result.error?
88
+ env[QUERY_PARAMS] = unpacked_query_params
89
+ env[PARAMS].merge!(unpacked_query_params)
110
90
  end
111
91
 
112
- def validate_query_parameters!(operation, params)
113
- parameter_defs = operation.query_parameters
114
- return unless parameter_defs&.any?
92
+ def validate_cookie_params!(operation, env)
93
+ cookie_parameters = operation.cookie_parameters
94
+ return unless cookie_parameters&.any?
115
95
 
116
- json_schema = build_json_schema(parameter_defs)
117
- errors = SchemaValidation.new(json_schema).validate(params)
118
- throw_error(400, serialize_parameter_errors(errors)) if errors.any?
96
+ unpacked_params = OpenapiParameters::Cookie.new(cookie_parameters).unpack(env['HTTP_COOKIE'])
97
+ validation_result = operation.schemas.cookie_parameters_schema.validate(unpacked_params)
98
+ OpenapiFirst.error!(400, :cookie, validation_result:) if validation_result.error?
99
+ env[COOKIE_PARAMS] = unpacked_params
119
100
  end
120
101
 
121
- def serialize_parameter_errors(validation_errors)
122
- validation_errors.map do |error|
123
- pointer = error['data_pointer'][1..].to_s
124
- {
125
- source: { parameter: pointer }
126
- }.update(ValidationFormat.error_details(error))
127
- end
102
+ def validate_header_params!(operation, env)
103
+ header_parameters = operation.header_parameters
104
+ return if header_parameters.empty?
105
+
106
+ unpacked_header_params = OpenapiParameters::Header.new(header_parameters).unpack_env(env)
107
+ validation_result = operation.schemas.header_parameters_schema.validate(unpacked_header_params)
108
+ OpenapiFirst.error!(400, :header, validation_result:) if validation_result.error?
109
+ env[HEADER_PARAMS] = unpacked_header_params
128
110
  end
129
111
  end
130
112
  end
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'multi_json'
4
4
  require_relative 'use_router'
5
- require_relative 'validation_format'
6
5
 
7
6
  module OpenapiFirst
8
7
  class ResponseValidation
@@ -26,8 +25,9 @@ module OpenapiFirst
26
25
  return validate_status_only(operation, status) if status == 204
27
26
 
28
27
  content_type = headers[Rack::CONTENT_TYPE]
29
- response_schema = operation.response_schema_for(status, content_type)
28
+ response_schema = operation.response_body_schema(status, content_type)
30
29
  validate_response_body(response_schema, body) if response_schema
30
+ validate_response_headers(operation, status, headers)
31
31
  end
32
32
 
33
33
  private
@@ -40,14 +40,45 @@ module OpenapiFirst
40
40
  full_body = +''
41
41
  response.each { |chunk| full_body << chunk }
42
42
  data = full_body.empty? ? {} : load_json(full_body)
43
- errors = schema.validate(data)
44
- errors = errors.to_a.map! do |error|
45
- format_error(error)
43
+ validation = schema.validate(data)
44
+ raise ResponseBodyInvalidError, validation.message if validation.error?
45
+ end
46
+
47
+ def validate_response_headers(operation, status, response_headers)
48
+ response_header_definitions = operation.response_for(status)&.dig('headers')
49
+ return unless response_header_definitions
50
+
51
+ unpacked_headers = unpack_response_headers(response_header_definitions, response_headers)
52
+ response_header_definitions.each do |name, definition|
53
+ next if name == 'Content-Type'
54
+
55
+ validate_response_header(name, definition, unpacked_headers, openapi_version: operation.openapi_version)
56
+ end
57
+ end
58
+
59
+ def validate_response_header(name, definition, unpacked_headers, openapi_version:)
60
+ unless unpacked_headers.key?(name)
61
+ raise ResponseHeaderInvalidError, "Required response header '#{name}' is missing" if definition['required']
62
+
63
+ return
64
+ end
65
+
66
+ return unless definition.key?('schema')
67
+
68
+ validation = SchemaValidation.new(definition['schema'], openapi_version:)
69
+ value = unpacked_headers[name]
70
+ validation_result = validation.validate(value)
71
+ raise ResponseHeaderInvalidError, validation_result.message if validation_result.error?
72
+ end
73
+
74
+ def unpack_response_headers(response_header_definitions, response_headers)
75
+ headers_as_parameters = response_header_definitions.map do |name, definition|
76
+ definition.merge('name' => name)
46
77
  end
47
- raise ResponseBodyInvalidError, errors.join(', ') if errors.any?
78
+ OpenapiParameters::Header.new(headers_as_parameters).unpack(response_headers)
48
79
  end
49
80
 
50
- def format_error(error)
81
+ def format_response_error(error)
51
82
  return "Write-only field appears in response: #{error['data_pointer']}" if error['type'] == 'writeOnly'
52
83
 
53
84
  JSONSchemer::Errors.pretty(error)
@@ -7,7 +7,7 @@ module OpenapiFirst
7
7
  class ResponseValidator
8
8
  def initialize(spec)
9
9
  @spec = spec
10
- @router = Router.new(->(_env) {}, spec: spec, raise_error: true)
10
+ @router = Router.new(->(_env) {}, spec:, raise_error: true)
11
11
  @response_validation = ResponseValidation.new(->(response) { response.to_a })
12
12
  end
13
13