openapi_first 1.0.0.beta5 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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