openapi_first 1.0.0.beta5 → 1.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +2 -1
  3. data/CHANGELOG.md +23 -2
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +16 -18
  6. data/Gemfile.rack2 +15 -0
  7. data/Gemfile.rack2.lock +99 -0
  8. data/README.md +99 -130
  9. data/lib/openapi_first/body_parser.rb +29 -0
  10. data/lib/openapi_first/configuration.rb +20 -0
  11. data/lib/openapi_first/definition/cookie_parameters.rb +12 -0
  12. data/lib/openapi_first/definition/header_parameters.rb +12 -0
  13. data/lib/openapi_first/definition/operation.rb +116 -0
  14. data/lib/openapi_first/definition/parameters.rb +47 -0
  15. data/lib/openapi_first/definition/path_item.rb +32 -0
  16. data/lib/openapi_first/definition/path_parameters.rb +12 -0
  17. data/lib/openapi_first/definition/query_parameters.rb +12 -0
  18. data/lib/openapi_first/definition/request_body.rb +43 -0
  19. data/lib/openapi_first/definition/response.rb +25 -0
  20. data/lib/openapi_first/definition/responses.rb +83 -0
  21. data/lib/openapi_first/definition.rb +61 -8
  22. data/lib/openapi_first/error_response.rb +22 -27
  23. data/lib/openapi_first/errors.rb +2 -14
  24. data/lib/openapi_first/failure.rb +55 -0
  25. data/lib/openapi_first/middlewares/request_validation.rb +52 -0
  26. data/lib/openapi_first/middlewares/response_validation.rb +35 -0
  27. data/lib/openapi_first/plugins/default/error_response.rb +74 -0
  28. data/lib/openapi_first/plugins/default.rb +11 -0
  29. data/lib/openapi_first/plugins/jsonapi/error_response.rb +58 -0
  30. data/lib/openapi_first/plugins/jsonapi.rb +11 -0
  31. data/lib/openapi_first/plugins.rb +9 -7
  32. data/lib/openapi_first/request_validation/request_body_validator.rb +41 -0
  33. data/lib/openapi_first/request_validation/validator.rb +81 -0
  34. data/lib/openapi_first/response_validation/validator.rb +101 -0
  35. data/lib/openapi_first/runtime_request.rb +84 -0
  36. data/lib/openapi_first/runtime_response.rb +31 -0
  37. data/lib/openapi_first/schema/validation_error.rb +18 -0
  38. data/lib/openapi_first/schema/validation_result.rb +32 -0
  39. data/lib/openapi_first/{json_schema.rb → schema.rb} +9 -5
  40. data/lib/openapi_first/version.rb +1 -1
  41. data/lib/openapi_first.rb +32 -28
  42. data/openapi_first.gemspec +10 -9
  43. metadata +55 -67
  44. data/.rspec +0 -3
  45. data/.rubocop.yml +0 -14
  46. data/Rakefile +0 -15
  47. data/benchmarks/Gemfile +0 -16
  48. data/benchmarks/Gemfile.lock +0 -142
  49. data/benchmarks/README.md +0 -29
  50. data/benchmarks/apps/committee_with_hanami_api.ru +0 -26
  51. data/benchmarks/apps/committee_with_response_validation.ru +0 -29
  52. data/benchmarks/apps/committee_with_sinatra.ru +0 -31
  53. data/benchmarks/apps/grape.ru +0 -21
  54. data/benchmarks/apps/hanami_api.ru +0 -21
  55. data/benchmarks/apps/hanami_router.ru +0 -14
  56. data/benchmarks/apps/openapi.yaml +0 -268
  57. data/benchmarks/apps/openapi_first_with_hanami_api.ru +0 -24
  58. data/benchmarks/apps/openapi_first_with_plain_rack.ru +0 -32
  59. data/benchmarks/apps/openapi_first_with_response_validation.ru +0 -25
  60. data/benchmarks/apps/openapi_first_with_sinatra.ru +0 -29
  61. data/benchmarks/apps/roda.ru +0 -27
  62. data/benchmarks/apps/sinatra.ru +0 -26
  63. data/benchmarks/apps/syro.ru +0 -25
  64. data/benchmarks/benchmark-wrk.sh +0 -3
  65. data/benchmarks/benchmarks.rb +0 -48
  66. data/benchmarks/post.lua +0 -3
  67. data/bin/console +0 -15
  68. data/bin/setup +0 -8
  69. data/examples/README.md +0 -13
  70. data/examples/app.rb +0 -18
  71. data/examples/config.ru +0 -7
  72. data/examples/openapi.yaml +0 -29
  73. data/lib/openapi_first/body_parser_middleware.rb +0 -40
  74. data/lib/openapi_first/config.rb +0 -20
  75. data/lib/openapi_first/error_responses/default.rb +0 -58
  76. data/lib/openapi_first/error_responses/json_api.rb +0 -58
  77. data/lib/openapi_first/json_schema/result.rb +0 -17
  78. data/lib/openapi_first/operation.rb +0 -170
  79. data/lib/openapi_first/request_body_validator.rb +0 -41
  80. data/lib/openapi_first/request_validation.rb +0 -118
  81. data/lib/openapi_first/request_validation_error.rb +0 -31
  82. data/lib/openapi_first/response_validation.rb +0 -93
  83. data/lib/openapi_first/response_validator.rb +0 -21
  84. data/lib/openapi_first/router.rb +0 -102
  85. data/lib/openapi_first/string_keyed_hash.rb +0 -20
  86. data/lib/openapi_first/use_router.rb +0 -18
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'set'
5
+ require_relative 'request_body'
6
+ require_relative 'query_parameters'
7
+ require_relative 'header_parameters'
8
+ require_relative 'path_parameters'
9
+ require_relative 'cookie_parameters'
10
+ require_relative 'responses'
11
+
12
+ module OpenapiFirst
13
+ class Definition
14
+ class Operation
15
+ extend Forwardable
16
+ def_delegators :operation_object,
17
+ :[],
18
+ :dig
19
+
20
+ WRITE_METHODS = Set.new(%w[post put patch delete]).freeze
21
+ private_constant :WRITE_METHODS
22
+
23
+ attr_reader :path, :method, :openapi_version
24
+
25
+ def initialize(path, request_method, path_item_object, openapi_version:)
26
+ @path = path
27
+ @method = request_method
28
+ @path_item_object = path_item_object
29
+ @openapi_version = openapi_version
30
+ @operation_object = @path_item_object[request_method]
31
+ end
32
+
33
+ def operation_id
34
+ operation_object['operationId']
35
+ end
36
+
37
+ def read?
38
+ !write?
39
+ end
40
+
41
+ def write?
42
+ WRITE_METHODS.include?(method)
43
+ end
44
+
45
+ def request_body
46
+ @request_body ||= RequestBody.new(operation_object['requestBody'], self) if operation_object['requestBody']
47
+ end
48
+
49
+ def response_status_defined?(status)
50
+ responses.status_defined?(status)
51
+ end
52
+
53
+ def_delegators :responses, :response_for
54
+
55
+ def schema_for(content_type)
56
+ content = @request_body_object['content']
57
+ return unless content&.any?
58
+
59
+ content_schemas&.fetch(content_type) do
60
+ type = content_type.split(';')[0]
61
+ content_schemas[type] || content_schemas["#{type.split('/')[0]}/*"] || content_schemas['*/*']
62
+ end
63
+ end
64
+
65
+ def name
66
+ @name ||= "#{method.upcase} #{path} (#{operation_id})"
67
+ end
68
+
69
+ def query_parameters
70
+ @query_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'query' }, QueryParameters)
71
+ end
72
+
73
+ def path_parameters
74
+ @path_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'path' }, PathParameters)
75
+ end
76
+
77
+ IGNORED_HEADERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
78
+ private_constant :IGNORED_HEADERS
79
+
80
+ def header_parameters
81
+ @header_parameters ||= build_parameters(find_header_parameters, HeaderParameters)
82
+ end
83
+
84
+ def cookie_parameters
85
+ @cookie_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'cookie' }, CookieParameters)
86
+ end
87
+
88
+ private
89
+
90
+ def all_parameters
91
+ @all_parameters ||= begin
92
+ parameters = @path_item_object['parameters']&.dup || []
93
+ parameters_on_operation = operation_object['parameters']
94
+ parameters.concat(parameters_on_operation) if parameters_on_operation
95
+ parameters
96
+ end
97
+ end
98
+
99
+ def responses
100
+ @responses ||= Responses.new(self, operation_object['responses'])
101
+ end
102
+
103
+ attr_reader :operation_object
104
+
105
+ def build_parameters(parameters, klass)
106
+ klass.new(parameters, openapi_version:) if parameters.any?
107
+ end
108
+
109
+ def find_header_parameters
110
+ all_parameters.filter do |p|
111
+ p['in'] == 'header' && !IGNORED_HEADERS.include?(p['name'])
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require_relative '../schema'
5
+
6
+ module OpenapiFirst
7
+ class Parameters
8
+ extend Forwardable
9
+
10
+ def initialize(parameter_definitions, openapi_version:)
11
+ @parameter_definitions = parameter_definitions
12
+ @openapi_version = openapi_version
13
+ end
14
+
15
+ def_delegators :parameters, :map
16
+
17
+ def empty?
18
+ @parameter_definitions.empty?
19
+ end
20
+
21
+ def schema
22
+ @schema ||= build_schema
23
+ end
24
+
25
+ def parameters
26
+ @parameter_definitions.map do |parameter_object|
27
+ OpenapiParameters::Parameter.new(parameter_object)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def build_schema
34
+ init_schema = {
35
+ 'type' => 'object',
36
+ 'properties' => {},
37
+ 'required' => []
38
+ }
39
+ schema = @parameter_definitions.each_with_object(init_schema) do |parameter_def, result|
40
+ parameter = OpenapiParameters::Parameter.new(parameter_def)
41
+ result['properties'][parameter.name] = parameter.schema if parameter.schema
42
+ result['required'] << parameter.name if parameter.required?
43
+ end
44
+ Schema.new(schema, openapi_version: @openapi_version)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'operation'
4
+
5
+ module OpenapiFirst
6
+ class Definition
7
+ class PathItem
8
+ def initialize(path, path_item_object, openapi_version:)
9
+ @path = path
10
+ @path_item_object = path_item_object
11
+ @openapi_version = openapi_version
12
+ end
13
+
14
+ attr_reader :path
15
+
16
+ def operation(request_method)
17
+ return unless @path_item_object[request_method]
18
+
19
+ Operation.new(
20
+ @path, request_method, @path_item_object, openapi_version: @openapi_version
21
+ )
22
+ end
23
+
24
+ METHODS = %w[get head post put patch delete trace options].freeze
25
+ private_constant :METHODS
26
+
27
+ def operations
28
+ @operations ||= @path_item_object.slice(*METHODS).keys.map { |method| operation(method) }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openapi_parameters'
4
+ require_relative 'parameters'
5
+
6
+ module OpenapiFirst
7
+ class PathParameters < Parameters
8
+ def unpack(original_path_params)
9
+ OpenapiParameters::Path.new(@parameter_definitions).unpack(original_path_params)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openapi_parameters'
4
+ require_relative 'parameters'
5
+
6
+ module OpenapiFirst
7
+ class QueryParameters < Parameters
8
+ def unpack(env)
9
+ OpenapiParameters::Query.new(@parameter_definitions).unpack(env['QUERY_STRING'])
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../schema'
4
+
5
+ module OpenapiFirst
6
+ class RequestBody
7
+ def initialize(request_body_object, operation)
8
+ @request_body_object = request_body_object
9
+ @operation = operation
10
+ end
11
+
12
+ def description
13
+ @request_body_object['description']
14
+ end
15
+
16
+ def required?
17
+ !!@request_body_object['required']
18
+ end
19
+
20
+ def schema_for(content_type)
21
+ content = @request_body_object['content']
22
+ return unless content&.any?
23
+
24
+ content_schemas&.fetch(content_type) do
25
+ type = content_type.split(';')[0]
26
+ content_schemas[type] || content_schemas["#{type.split('/')[0]}/*"] || content_schemas['*/*']
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def content_schemas
33
+ @content_schemas ||= @request_body_object['content']&.each_with_object({}) do |kv, result|
34
+ type, media_type = kv
35
+ schema_object = media_type['schema']
36
+ next unless schema_object
37
+
38
+ result[type] = Schema.new(schema_object, write: @operation.write?,
39
+ openapi_version: @operation.openapi_version)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ class Definition
5
+ class Response
6
+ def initialize(operation:, status:, response_object:, content_type:, content_schema:)
7
+ @operation = operation
8
+ @response_object = response_object
9
+ @status = status
10
+ @content_type = content_type
11
+ @content_schema = content_schema
12
+ end
13
+
14
+ attr_reader :operation, :status, :content_type, :content_schema
15
+
16
+ def headers
17
+ @response_object['headers']
18
+ end
19
+
20
+ def description
21
+ @response_object['description']
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'response'
4
+
5
+ module OpenapiFirst
6
+ class Definition
7
+ # @visibility private
8
+ class Responses
9
+ def initialize(operation, responses_object)
10
+ @operation = operation
11
+ @responses_object = responses_object
12
+ end
13
+
14
+ def status_defined?(status)
15
+ !!find_response_object(status)
16
+ end
17
+
18
+ def response_for(status, response_content_type)
19
+ response_object = find_response_object(status)
20
+ return unless response_object
21
+ return response_without_content(status, response_object) unless content_defined?(response_object)
22
+
23
+ defined_content_type = find_defined_content_type(response_object, response_content_type)
24
+ return unless defined_content_type
25
+
26
+ content_schema = find_content_schema(response_object, response_content_type)
27
+ Response.new(operation:, status:, response_object:, content_type: defined_content_type, content_schema:)
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :openapi_version, :operation
33
+
34
+ def response_without_content(status, response_object)
35
+ Response.new(operation:, status:, response_object:, content_type: nil, content_schema: nil)
36
+ end
37
+
38
+ def find_defined_content_type(response_object, content_type)
39
+ return if content_type.nil?
40
+
41
+ content = response_object['content']
42
+ return content_type if content.key?(content_type)
43
+
44
+ type = content_type.split(';')[0]
45
+ return type if content.key?(type)
46
+
47
+ key = "#{type.split('/')[0]}/*"
48
+ return key if content.key?(key)
49
+
50
+ key = '*/*'
51
+ key if content.key?(key)
52
+ end
53
+
54
+ def content_defined?(response_object)
55
+ response_object['content']&.any?
56
+ end
57
+
58
+ def find_content_schema(response_object, response_content_type)
59
+ return unless response_content_type
60
+
61
+ content_object = find_response_body(response_object['content'], response_content_type)
62
+ content_schema_object = content_object&.fetch('schema', nil)
63
+ return unless content_schema_object
64
+
65
+ Schema.new(content_schema_object, write: false, openapi_version: operation.openapi_version)
66
+ end
67
+
68
+ def find_response_object(status)
69
+ @responses_object[status.to_s] ||
70
+ @responses_object["#{status / 100}XX"] ||
71
+ @responses_object["#{status / 100}xx"] ||
72
+ @responses_object['default']
73
+ end
74
+
75
+ def find_response_body(content, content_type)
76
+ content&.fetch(content_type) do |_|
77
+ type = content_type.split(';')[0]
78
+ content[type] || content["#{type.split('/')[0]}/*"] || content['*/*']
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -1,24 +1,77 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'operation'
3
+ require 'mustermann/template'
4
+ require_relative 'definition/path_item'
5
+ require_relative 'runtime_request'
4
6
 
5
7
  module OpenapiFirst
6
8
  # Represents an OpenAPI API Description document
7
9
  class Definition
8
- attr_reader :filepath, :operations
10
+ attr_reader :filepath, :paths, :openapi_version
9
11
 
10
12
  def initialize(resolved, filepath)
11
13
  @filepath = filepath
12
- methods = %w[get head post put patch delete trace options]
13
- @operations = resolved['paths'].flat_map do |path, path_item|
14
- path_item.slice(*methods).map do |request_method, _operation_object|
15
- Operation.new(path, request_method, path_item, openapi_version: detect_version(resolved))
16
- end
17
- end
14
+ @paths = resolved['paths']
15
+ @openapi_version = detect_version(resolved)
16
+ end
17
+
18
+ def request(rack_request)
19
+ path_item, path_params = find_path_item_and_params(rack_request.path)
20
+ operation = path_item&.operation(rack_request.request_method.downcase)
21
+ RuntimeRequest.new(
22
+ request: rack_request,
23
+ path_item:,
24
+ operation:,
25
+ path_params:
26
+ )
27
+ end
28
+
29
+ def response(rack_request, rack_response)
30
+ request(rack_request).response(rack_response)
31
+ end
32
+
33
+ def operations
34
+ @operations ||= path_items.flat_map(&:operations)
35
+ end
36
+
37
+ def path(pathname)
38
+ return unless paths.key?(pathname)
39
+
40
+ PathItem.new(pathname, paths[pathname], openapi_version:)
18
41
  end
19
42
 
20
43
  private
21
44
 
45
+ def path_items
46
+ @path_items ||= paths.flat_map do |path, path_item_object|
47
+ PathItem.new(path, path_item_object, openapi_version:)
48
+ end
49
+ end
50
+
51
+ def find_path_item_and_params(request_path)
52
+ if paths.key?(request_path)
53
+ return [
54
+ PathItem.new(request_path, paths[request_path], openapi_version:),
55
+ {}
56
+ ]
57
+ end
58
+ search_for_path_item(request_path)
59
+ end
60
+
61
+ def search_for_path_item(request_path)
62
+ paths.find do |path, path_item_object|
63
+ template = Mustermann::Template.new(path)
64
+ path_params = template.params(request_path)
65
+ next unless path_params
66
+ next unless path_params.size == template.names.size
67
+
68
+ return [
69
+ PathItem.new(path, path_item_object, openapi_version:),
70
+ path_params
71
+ ]
72
+ end
73
+ end
74
+
22
75
  def detect_version(resolved)
23
76
  (resolved['openapi'] || resolved['swagger'])[0..2]
24
77
  end
@@ -1,45 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiFirst
4
- # This is the base class for error responses
5
- class ErrorResponse
6
- ## @param request [Hash] The Rack request env
7
- ## @param request_validation_error [OpenapiFirst::RequestValidationError]
8
- def initialize(env, request_validation_error)
9
- @env = env
10
- @request_validation_error = request_validation_error
4
+ # This is the base module for error responses
5
+ module ErrorResponse
6
+ ## @param failure [OpenapiFirst::Failure]
7
+ def initialize(failure: nil)
8
+ @failure = failure
11
9
  end
12
10
 
13
- extend Forwardable
11
+ attr_reader :failure
14
12
 
15
- attr_reader :env, :request_validation_error
16
-
17
- def_delegators :@request_validation_error, :status, :location, :schema_validation
18
-
19
- def validation_output
20
- schema_validation&.output
13
+ # The response body
14
+ def body
15
+ raise NotImplementedError
21
16
  end
22
17
 
23
- def schema
24
- schema_validation&.schema
18
+ # The response content-type
19
+ def content_type
20
+ raise NotImplementedError
25
21
  end
26
22
 
27
- def data
28
- schema_validation&.data
29
- end
23
+ STATUS = {
24
+ not_found: 404,
25
+ method_not_allowed: 405,
26
+ unsupported_media_type: 415
27
+ }.freeze
28
+ private_constant :STATUS
30
29
 
31
- def message
32
- request_validation_error.message
30
+ # The response status
31
+ def status
32
+ STATUS[failure.error_type] || 400
33
33
  end
34
34
 
35
+ # Render this error response
35
36
  def render
36
37
  Rack::Response.new(body, status, Rack::CONTENT_TYPE => content_type).finish
37
38
  end
38
-
39
- def content_type = 'application/json'
40
-
41
- def body
42
- raise NotImplementedError
43
- end
44
39
  end
45
40
  end
@@ -2,20 +2,8 @@
2
2
 
3
3
  module OpenapiFirst
4
4
  class Error < StandardError; end
5
-
6
5
  class NotFoundError < Error; end
7
-
8
- class ResponseInvalid < Error; end
9
-
10
- class ResponseCodeNotFoundError < ResponseInvalid; end
11
-
12
- class ResponseContentTypeNotFoundError < ResponseInvalid; end
13
-
14
- class ResponseBodyInvalidError < ResponseInvalid; end
15
-
16
- class ResponseHeaderInvalidError < ResponseInvalid; end
17
-
18
- class BodyParsingError < Error; end
19
-
20
6
  class RequestInvalidError < Error; end
7
+ class ResponseNotFoundError < Error; end
8
+ class ResponseInvalidError < Error; end
21
9
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ class Failure
5
+ FAILURE = :openapi_first_validation_failure
6
+
7
+ TYPES = {
8
+ not_found: [NotFoundError, 'Request path is not defined.'],
9
+ method_not_allowed: [RequestInvalidError, 'Request method is not defined.'],
10
+ unsupported_media_type: [RequestInvalidError, 'Request content type is not defined.'],
11
+ invalid_body: [RequestInvalidError, 'Request body invalid:'],
12
+ invalid_query: [RequestInvalidError, 'Query parameter is invalid:'],
13
+ invalid_header: [RequestInvalidError, 'Request header is invalid:'],
14
+ invalid_path: [RequestInvalidError, 'Path segment is invalid:'],
15
+ invalid_cookie: [RequestInvalidError, 'Cookie value is invalid:'],
16
+ response_not_found: [ResponseNotFoundError, 'Response is not defined.'],
17
+ invalid_response_body: [ResponseInvalidError, 'Response body is invalid:'],
18
+ invalid_response_header: [ResponseInvalidError, 'Response header is invalid:']
19
+ }.freeze
20
+ private_constant :TYPES
21
+
22
+ # @param error_type [Symbol] See Failure::TYPES.keys
23
+ # @param errors [Array<OpenapiFirst::Schema::ValidationResult>]
24
+ def self.fail!(error_type, message: nil, errors: nil)
25
+ throw FAILURE, new(
26
+ error_type,
27
+ message:,
28
+ errors:
29
+ )
30
+ end
31
+
32
+ # @param type [Symbol] See TYPES.keys
33
+ # @param message [String] A generic error message
34
+ # @param errors [Array<OpenapiFirst::Schema::ValidationError>]
35
+ def initialize(error_type, message: nil, errors: nil)
36
+ unless TYPES.key?(error_type)
37
+ raise ArgumentError,
38
+ "error_type must be one of #{TYPES.keys} but was #{error_type.inspect}"
39
+ end
40
+
41
+ @error_type = error_type
42
+ @message = message
43
+ @errors = errors
44
+ end
45
+
46
+ attr_reader :error_type, :message, :errors
47
+
48
+ # Raise an exception that fits the failure.
49
+ def raise!
50
+ exception, message_prefix = TYPES.fetch(error_type)
51
+
52
+ raise exception, "#{message_prefix} #{@message || errors&.map(&:error)&.join('. ')}"
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+ module OpenapiFirst
5
+ module Middlewares
6
+ # A Rack middleware to validate requests against an OpenAPI API description
7
+ class RequestValidation
8
+ # @param app The parent Rack application
9
+ # @param options An optional Hash of configuration options to override defaults
10
+ # :raise_error A Boolean indicating whether to raise an error if validation fails.
11
+ # default: false
12
+ # :error_response The Class to use for error responses.
13
+ # default: OpenapiFirst::Plugins::Default::ErrorResponse (Config.default_options.error_response)
14
+ def initialize(app, options = {})
15
+ @app = app
16
+ @raise = options.fetch(:raise_error, OpenapiFirst.configuration.request_validation_raise_error)
17
+ @error_response_class = error_response(options[:error_response])
18
+
19
+ spec = options.fetch(:spec)
20
+ raise "You have to pass spec: when initializing #{self.class}" unless spec
21
+
22
+ @definition = spec.is_a?(Definition) ? spec : OpenapiFirst.load(spec)
23
+ end
24
+
25
+ def call(env)
26
+ request = find_request(env)
27
+ return @app.call(env) unless request
28
+
29
+ failure = if @raise
30
+ request.validate!
31
+ else
32
+ request.validate
33
+ end
34
+ return @error_response_class.new(failure:).render if failure
35
+
36
+ @app.call(env)
37
+ end
38
+
39
+ private
40
+
41
+ def find_request(env)
42
+ env[REQUEST] ||= @definition.request(Rack::Request.new(env))
43
+ end
44
+
45
+ def error_response(mod)
46
+ return OpenapiFirst.plugin(mod)::ErrorResponse if mod.is_a?(Symbol)
47
+
48
+ mod || OpenapiFirst.configuration.request_validation_error_response
49
+ end
50
+ end
51
+ end
52
+ end