openapi_first 0.7.0.alpha1 → 3.1.0.beta1

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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +585 -2
  3. data/LICENSE.txt +21 -0
  4. data/README.md +360 -114
  5. data/lib/openapi_first/builder.rb +214 -0
  6. data/lib/openapi_first/child_configuration.rb +26 -0
  7. data/lib/openapi_first/configuration.rb +76 -0
  8. data/lib/openapi_first/definition.rb +98 -13
  9. data/lib/openapi_first/error_response.rb +40 -0
  10. data/lib/openapi_first/error_responses/default.rb +74 -0
  11. data/lib/openapi_first/error_responses/jsonapi.rb +60 -0
  12. data/lib/openapi_first/errors.rb +40 -0
  13. data/lib/openapi_first/failure.rb +77 -0
  14. data/lib/openapi_first/file_loader.rb +22 -0
  15. data/lib/openapi_first/header.rb +9 -0
  16. data/lib/openapi_first/json.rb +25 -0
  17. data/lib/openapi_first/middlewares/request_validation.rb +58 -0
  18. data/lib/openapi_first/middlewares/response_validation.rb +41 -0
  19. data/lib/openapi_first/parsed_request.rb +5 -0
  20. data/lib/openapi_first/ref_resolver.rb +193 -0
  21. data/lib/openapi_first/registry.rb +44 -0
  22. data/lib/openapi_first/request.rb +79 -0
  23. data/lib/openapi_first/request_body_parsers.rb +64 -0
  24. data/lib/openapi_first/request_validator.rb +32 -0
  25. data/lib/openapi_first/response.rb +43 -0
  26. data/lib/openapi_first/response_body_parsers.rb +31 -0
  27. data/lib/openapi_first/response_parser.rb +50 -0
  28. data/lib/openapi_first/response_validator.rb +16 -48
  29. data/lib/openapi_first/router/find_content.rb +17 -0
  30. data/lib/openapi_first/router/find_response.rb +53 -0
  31. data/lib/openapi_first/router/path_template.rb +48 -0
  32. data/lib/openapi_first/router.rb +84 -57
  33. data/lib/openapi_first/schema/hash.rb +39 -0
  34. data/lib/openapi_first/schema/validation_error.rb +45 -0
  35. data/lib/openapi_first/schema/validation_result.rb +30 -0
  36. data/lib/openapi_first/test/app.rb +30 -0
  37. data/lib/openapi_first/test/callable.rb +25 -0
  38. data/lib/openapi_first/test/configuration.rb +76 -0
  39. data/lib/openapi_first/test/coverage/covered_request.rb +11 -0
  40. data/lib/openapi_first/test/coverage/covered_response.rb +11 -0
  41. data/lib/openapi_first/test/coverage/plan.rb +77 -0
  42. data/lib/openapi_first/test/coverage/request_task.rb +42 -0
  43. data/lib/openapi_first/test/coverage/response_task.rb +42 -0
  44. data/lib/openapi_first/test/coverage/route_task.rb +13 -0
  45. data/lib/openapi_first/test/coverage/terminal_formatter.rb +115 -0
  46. data/lib/openapi_first/test/coverage/tracker.rb +32 -0
  47. data/lib/openapi_first/test/coverage.rb +102 -0
  48. data/lib/openapi_first/test/methods.rb +62 -0
  49. data/lib/openapi_first/test/minitest_helpers.rb +28 -0
  50. data/lib/openapi_first/test/observe.rb +37 -0
  51. data/lib/openapi_first/test/observer_middleware.rb +23 -0
  52. data/lib/openapi_first/test/plain_helpers.rb +31 -0
  53. data/lib/openapi_first/test.rb +171 -0
  54. data/lib/openapi_first/validated_request.rb +79 -0
  55. data/lib/openapi_first/validated_response.rb +53 -0
  56. data/lib/openapi_first/validators/request_body.rb +25 -0
  57. data/lib/openapi_first/validators/request_parameters.rb +49 -0
  58. data/lib/openapi_first/validators/response_body.rb +27 -0
  59. data/lib/openapi_first/validators/response_headers.rb +40 -0
  60. data/lib/openapi_first/version.rb +1 -1
  61. data/lib/openapi_first.rb +67 -35
  62. metadata +99 -133
  63. data/.github/CODEOWNERS +0 -1
  64. data/.gitignore +0 -11
  65. data/.rspec +0 -3
  66. data/.rubocop.yml +0 -14
  67. data/.travis.yml +0 -8
  68. data/Gemfile +0 -11
  69. data/Gemfile.lock +0 -126
  70. data/Rakefile +0 -15
  71. data/benchmarks/Gemfile +0 -13
  72. data/benchmarks/Gemfile.lock +0 -144
  73. data/benchmarks/apps/committee.ru +0 -30
  74. data/benchmarks/apps/grape.ru +0 -21
  75. data/benchmarks/apps/hanami_router.ru +0 -14
  76. data/benchmarks/apps/openapi.yaml +0 -86
  77. data/benchmarks/apps/openapi_first.ru +0 -22
  78. data/benchmarks/apps/openapi_first_resolve_only.ru +0 -23
  79. data/benchmarks/apps/sinatra.ru +0 -26
  80. data/benchmarks/apps/syro.ru +0 -25
  81. data/benchmarks/benchmarks.rb +0 -46
  82. data/bin/console +0 -15
  83. data/bin/setup +0 -8
  84. data/examples/README.md +0 -13
  85. data/examples/app.rb +0 -12
  86. data/examples/config.ru +0 -7
  87. data/examples/openapi.yaml +0 -29
  88. data/lib/openapi_first/app.rb +0 -27
  89. data/lib/openapi_first/coverage.rb +0 -28
  90. data/lib/openapi_first/operation.rb +0 -48
  91. data/lib/openapi_first/operation_resolver.rb +0 -43
  92. data/lib/openapi_first/request_validation.rb +0 -144
  93. data/lib/openapi_first/utils.rb +0 -31
  94. data/lib/openapi_first/validation.rb +0 -15
  95. data/lib/openapi_first/validation_format.rb +0 -31
  96. data/openapi_first.gemspec +0 -46
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ # A failure object returned when validation or parsing of a request or response has failed.
5
+ # This returned in ValidatedRequest#error and ValidatedResponse#error.
6
+ class Failure
7
+ TYPES = {
8
+ not_found: [NotFoundError, 'Not found.'],
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_content_type_not_found: [ResponseNotFoundError],
17
+ response_status_not_found: [ResponseNotFoundError],
18
+ invalid_response_body: [ResponseInvalidError, 'Response body is invalid:'],
19
+ invalid_response_header: [ResponseInvalidError, 'Response header is invalid:']
20
+ }.freeze
21
+ private_constant :TYPES
22
+
23
+ # @param type [Symbol] See Failure::TYPES.keys
24
+ # @param errors [Array<OpenapiFirst::Schema::ValidationError>]
25
+ def self.fail!(type, message: nil, errors: nil)
26
+ throw FAILURE, new(
27
+ type,
28
+ message:,
29
+ errors:
30
+ )
31
+ end
32
+
33
+ # @param type [Symbol] See TYPES.keys
34
+ # @param message [String] A generic error message
35
+ # @param errors [Array<OpenapiFirst::Schema::ValidationError>]
36
+ def initialize(type, message: nil, errors: nil)
37
+ unless TYPES.key?(type)
38
+ raise ArgumentError,
39
+ "type must be one of #{TYPES.keys} but was #{type.inspect}"
40
+ end
41
+
42
+ @type = type
43
+ @message = message
44
+ @errors = errors
45
+ end
46
+
47
+ # @attr_reader [Symbol] type The type of the failure. See TYPES.keys.
48
+ # Example: :invalid_body
49
+ attr_reader :type
50
+
51
+ # @attr_reader [Array<OpenapiFirst::Schema::ValidationError>] errors Schema validation errors
52
+ attr_reader :errors
53
+
54
+ # A generic error message
55
+ def message
56
+ @message ||= exception_message
57
+ end
58
+
59
+ def exception(context = nil)
60
+ TYPES.fetch(type).first.new(exception_message, context)
61
+ end
62
+
63
+ def exception_message
64
+ _, message_prefix = TYPES.fetch(type)
65
+
66
+ [message_prefix, @message || generate_message].compact.join(' ')
67
+ end
68
+
69
+ private
70
+
71
+ def generate_message
72
+ messages = errors&.take(3)&.map(&:message)
73
+ messages << "... (#{errors.size} errors total)" if errors && errors.size > 3
74
+ messages&.join('. ')
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+
6
+ module OpenapiFirst
7
+ # @!visibility private
8
+ module FileLoader
9
+ module_function
10
+
11
+ def load(file_path)
12
+ raise FileNotFoundError, "File not found #{file_path.inspect}" unless File.exist?(file_path)
13
+
14
+ body = File.read(file_path)
15
+ extname = File.extname(file_path)
16
+ return ::JSON.parse(body) if extname == '.json'
17
+ return YAML.unsafe_load(body) if ['.yaml', '.yml'].include?(extname)
18
+
19
+ body
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ Header = Data.define(:name, :required?, :schema, :node) do
5
+ def resolved_schema
6
+ node['schema']&.resolved
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This tries to load MultiJson if available to keep compatibility
4
+ # with MultiJson until next major version
5
+
6
+ begin
7
+ require 'multi_json'
8
+ module OpenapiFirst
9
+ # Compatibility with MultiJson
10
+ # @visibility private
11
+ module JSON
12
+ ParserError = MultiJson::ParseError
13
+
14
+ def self.parse(string)
15
+ MultiJson.load(string)
16
+ end
17
+
18
+ def self.generate(object)
19
+ MultiJson.dump(object)
20
+ end
21
+ end
22
+ end
23
+ rescue LoadError
24
+ require 'json'
25
+ end
@@ -0,0 +1,58 @@
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 spec [String, Symbol, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
10
+ # If you pass a Symbol, it will load the OAD registered via `OpenapiFirst.register`
11
+ # If you leave this blank, it will load the OAD registered as `:default`
12
+ # @param options Hash
13
+ # :spec [String, Symbol, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
14
+ # This will be deprecated. Please use spec argument instead.
15
+ # :raise_error A Boolean indicating whether to raise an error if validation fails.
16
+ # default: false
17
+ # :error_response The Class to use for error responses.
18
+ # This can be a Symbol-name of an registered error response (:default, :jsonapi)
19
+ # or it can be set to false to disable returning a response.
20
+ # default: OpenapiFirst::Plugins::Default::ErrorResponse (Config.default_options.error_response)
21
+ def initialize(app, spec = nil, options = {})
22
+ @app = app
23
+ if spec.is_a?(Hash)
24
+ options = spec
25
+ spec = options[:spec]
26
+ end
27
+ @raise = options.fetch(:raise_error, OpenapiFirst.configuration.request_validation_raise_error)
28
+ @error_response_class = error_response_option(options[:error_response])
29
+
30
+ spec ||= :default
31
+ spec = OpenapiFirst[spec] if spec.is_a?(Symbol)
32
+
33
+ @definition = spec.is_a?(Definition) ? spec : OpenapiFirst.load(spec)
34
+ end
35
+
36
+ # @attr_reader [Proc] app The upstream Rack application
37
+ attr_reader :app
38
+
39
+ def call(env)
40
+ validated = @definition.validate_request(Rack::Request.new(env), raise_error: @raise)
41
+ env[REQUEST] = validated
42
+ failure = validated.error
43
+ return @error_response_class.new(failure:).render if failure && @error_response_class
44
+
45
+ @app.call(env)
46
+ end
47
+
48
+ private
49
+
50
+ def error_response_option(value)
51
+ return if value == false
52
+ return OpenapiFirst.find_error_response(value) if value.is_a?(Symbol)
53
+
54
+ value || OpenapiFirst.configuration.request_validation_error_response
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+
5
+ module OpenapiFirst
6
+ module Middlewares
7
+ # A Rack middleware to validate requests against an OpenAPI API description
8
+ class ResponseValidation
9
+ # @param spec [String, Symbol, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
10
+ # If you pass a Symbol, it will load the OAD registered via `OpenapiFirst.register`
11
+ # If you leave this blank, it will load the OAD registered as `:default`
12
+ # @param options Hash
13
+ # :spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
14
+ # This will be deprecated. Please use spec argument instead.
15
+ # :raise_error [Boolean] Whether to raise an error if validation fails. default: true
16
+ def initialize(app, spec = nil, options = {})
17
+ @app = app
18
+ if spec.is_a?(Hash)
19
+ options = spec
20
+ spec = options[:spec]
21
+ end
22
+ @raise = options.fetch(:raise_error, OpenapiFirst.configuration.response_validation_raise_error)
23
+
24
+ spec ||= :default
25
+ spec = OpenapiFirst[spec] if spec.is_a?(Symbol)
26
+
27
+ @definition = spec.is_a?(Definition) ? spec : OpenapiFirst.load(spec)
28
+ end
29
+
30
+ # @attr_reader [Proc] app The upstream Rack application
31
+ attr_reader :app
32
+
33
+ def call(env)
34
+ status, headers, body = @app.call(env)
35
+ @definition.validate_response(Rack::Request.new(env), Rack::Response[status, headers, body],
36
+ raise_error: @raise)
37
+ [status, headers, body]
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ ParsedRequest = Data.define(:path, :query, :headers, :body, :cookies)
5
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json_schemer'
4
+
5
+ module OpenapiFirst
6
+ # This is here to give traverse an OAD while keeping $refs intact
7
+ # @visibility private
8
+ module RefResolver
9
+ def self.load(filepath)
10
+ contents = OpenapiFirst::FileLoader.load(filepath)
11
+ self.for(contents, filepath:)
12
+ end
13
+
14
+ def self.for(value, filepath: nil, context: value)
15
+ case value
16
+ when ::Hash
17
+ resolver = Hash.new(value, context:, filepath:)
18
+ if value.key?('$ref')
19
+ probe = resolver.resolve_ref(value['$ref'])
20
+ return probe if probe.is_a?(Array)
21
+ end
22
+ resolver
23
+ when ::Array
24
+ Array.new(value, context:, filepath:)
25
+ when ::NilClass
26
+ nil
27
+ else
28
+ Simple.new(value)
29
+ end
30
+ end
31
+
32
+ # @visibility private
33
+ module Diggable
34
+ def dig(*keys)
35
+ keys.inject(self) do |result, key|
36
+ break unless result.respond_to?(:[])
37
+
38
+ result[key]
39
+ end
40
+ end
41
+ end
42
+
43
+ # @visibility private
44
+ module Resolvable
45
+ def initialize(value, context: value, filepath: nil)
46
+ @value = value
47
+ @context = context
48
+ @filepath = filepath
49
+ @dir = if filepath
50
+ File.dirname(File.absolute_path(filepath))
51
+ else
52
+ Dir.pwd
53
+ end
54
+ end
55
+
56
+ # The value of this node
57
+ attr_reader :value
58
+ # The path of the file sytem directory where this was loaded from
59
+ attr_reader :dir
60
+ # The object where this node was found in
61
+ attr_reader :context
62
+
63
+ attr_reader :filepath
64
+
65
+ def ==(_other)
66
+ raise "Don't call == on an unresolved value. Use .value == other instead."
67
+ end
68
+
69
+ def resolve_ref(pointer)
70
+ if pointer.start_with?('#')
71
+ value = Hana::Pointer.new(pointer[1..]).eval(context)
72
+ raise "Unknown reference #{pointer} in #{context}" unless value
73
+
74
+ return RefResolver.for(value, filepath:, context:)
75
+ end
76
+
77
+ relative_path, file_pointer = pointer.split('#')
78
+ full_path = File.expand_path(relative_path, dir)
79
+ return RefResolver.load(full_path) unless file_pointer
80
+
81
+ file_contents = FileLoader.load(full_path)
82
+ value = Hana::Pointer.new(file_pointer).eval(file_contents)
83
+ RefResolver.for(value, filepath: full_path, context: file_contents)
84
+ rescue OpenapiFirst::FileNotFoundError => e
85
+ message = "Problem with reference resolving #{pointer.inspect} in " \
86
+ "file #{File.absolute_path(filepath).inspect}: #{e.message}"
87
+ raise OpenapiFirst::FileNotFoundError, message
88
+ end
89
+ end
90
+
91
+ # @visibility private
92
+ class Simple
93
+ include Resolvable
94
+
95
+ def resolved = value
96
+ end
97
+
98
+ # @visibility private
99
+ class Hash
100
+ include Resolvable
101
+ include Diggable
102
+ include Enumerable
103
+
104
+ def ==(_other)
105
+ raise "Don't call == on an unresolved value. Use .value == other instead."
106
+ end
107
+
108
+ def resolved
109
+ return resolve_ref(value['$ref']).value if value.key?('$ref')
110
+
111
+ value
112
+ end
113
+
114
+ def [](key)
115
+ return resolve_ref(@value['$ref'])[key] if !@value.key?(key) && @value.key?('$ref')
116
+
117
+ RefResolver.for(@value[key], filepath:, context:)
118
+ end
119
+
120
+ def fetch(key)
121
+ return resolve_ref(@value['$ref']).fetch(key) if !@value.key?(key) && @value.key?('$ref')
122
+
123
+ RefResolver.for(@value.fetch(key), filepath:, context:)
124
+ end
125
+
126
+ def each
127
+ resolved.each_key do |key|
128
+ yield key, self[key]
129
+ end
130
+ end
131
+
132
+ # You have to pass configuration or ref_resolver
133
+ def schema(options)
134
+ base_uri = URI::File.build({ path: "#{dir}/" })
135
+ Schema.new(value:, context:, base_uri:, options:)
136
+ end
137
+ end
138
+
139
+ # @visibility private
140
+ # Defers initialization JSONSchemer::Schema, because that takes time.
141
+ class Schema
142
+ extend Forwardable
143
+
144
+ def initialize(value:, context:, base_uri:, options:)
145
+ @value = value
146
+ @context = context
147
+ @base_uri = base_uri
148
+ @options = options
149
+ end
150
+
151
+ attr_reader :value, :context, :base_uri, :options
152
+
153
+ def_delegators :schema, :validate, :valid?
154
+
155
+ def schema
156
+ @schema ||= begin
157
+ root_schema = JSONSchemer::Schema.new(context, base_uri:, **options)
158
+ JSONSchemer::Schema.new(value, nil, root_schema, base_uri:, **options)
159
+ end
160
+ end
161
+ end
162
+
163
+ # @visibility private
164
+ class Array
165
+ include Enumerable
166
+ include Resolvable
167
+ include Diggable
168
+
169
+ def [](index)
170
+ item = @value[index]
171
+ return resolve_ref(item['$ref']) if item.is_a?(::Hash) && item.key?('$ref')
172
+
173
+ RefResolver.for(item, filepath:, context:)
174
+ end
175
+
176
+ def each
177
+ resolved.each_with_index do |_item, index|
178
+ yield self[index]
179
+ end
180
+ end
181
+
182
+ def resolved
183
+ value.map do |item|
184
+ if item.respond_to?(:key?) && item.key?('$ref')
185
+ resolve_ref(item['$ref']).resolved
186
+ else
187
+ item
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ class NotRegisteredError < Error; end
5
+ class AlreadyRegisteredError < Error; end
6
+
7
+ # @visibility private
8
+ module Registry
9
+ def definitions
10
+ @definitions ||= {}
11
+ end
12
+
13
+ # Register an OpenAPI definition for testing
14
+ # @param path_or_definition [String, Definition] Path to the OpenAPI file or a Definition object
15
+ # @param as [Symbol] Name to register the API definition as
16
+ def register(path_or_definition, as: :default)
17
+ if definitions.key?(as) && as == :default
18
+ raise(
19
+ AlreadyRegisteredError,
20
+ "#{definitions[as].inspect} is already registered " \
21
+ "as ':default' so you cannot register #{path_or_definition.inspect} without " \
22
+ 'giving it a custom name. Please call register with a custom key like: ' \
23
+ "#{name}.register(#{path_or_definition.inspect}, as: :my_other_api)"
24
+ )
25
+ end
26
+
27
+ definition = OpenapiFirst.load(path_or_definition)
28
+ definitions[as] = definition
29
+ definition
30
+ end
31
+
32
+ def [](api)
33
+ return api if api.is_a?(Definition)
34
+
35
+ definitions.fetch(api) do
36
+ option = api == :default ? '' : ", as: #{api.inspect}"
37
+ raise(NotRegisteredError,
38
+ "API description '#{api.inspect}' not found." \
39
+ "Please call #{name}.register('myopenapi.yaml'#{option}) " \
40
+ 'once before running your app.')
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openapi_parameters'
4
+ require_relative 'parsed_request'
5
+ require_relative 'request_validator'
6
+ require_relative 'validated_request'
7
+ require_relative 'request_body_parsers'
8
+
9
+ module OpenapiFirst
10
+ # Represents one request definition of an OpenAPI description.
11
+ # Note that this is not the same as an OpenAPI 3.x Operation.
12
+ # An 3.x Operation object can accept multiple requests, because it can handle multiple content-types.
13
+ # This class represents one of those requests.
14
+ class Request
15
+ def initialize(path:, request_method:, operation_object:, # rubocop:disable Metrics/MethodLength
16
+ parameters:, content_type:, content_schema:, required_body:, key:)
17
+ @path = path
18
+ @request_method = request_method
19
+ @content_type = content_type
20
+ @content_schema = content_schema
21
+ @operation = operation_object
22
+ @allow_empty_content = content_type.nil? || required_body == false
23
+ @key = key
24
+ @query_parser = parameters.query&.then { |params| OpenapiParameters::Query.new(params) }
25
+ @path_parser = parameters.path&.then { |params| OpenapiParameters::Path.new(params) }
26
+ @headers_parser = parameters.header&.then { |params| OpenapiParameters::Header.new(params) }
27
+ @cookies_parser = parameters.cookie&.then { |params| OpenapiParameters::Cookie.new(params) }
28
+ @body_parsers = RequestBodyParsers[content_type] if content_type
29
+ @validator = RequestValidator.new(
30
+ content_schema:,
31
+ required_request_body: required_body == true,
32
+ path_schema: parameters.path_schema,
33
+ query_schema: parameters.query_schema,
34
+ header_schema: parameters.header_schema,
35
+ cookie_schema: parameters.cookie_schema
36
+ )
37
+ @parameters = parameters
38
+ end
39
+
40
+ attr_reader :content_type, :content_schema, :operation, :request_method, :path, :key, :query_schema, :parameters
41
+ private attr_reader :query_parser
42
+
43
+ def allow_empty_content?
44
+ @allow_empty_content
45
+ end
46
+
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
54
+ ValidatedRequest.new(request, parsed_request:, error:, request_definition: self, query_parser:)
55
+ end
56
+
57
+ def operation_id
58
+ @operation['operationId']
59
+ end
60
+
61
+ private
62
+
63
+ def parse_request(request, route_params:)
64
+ ParsedRequest.new(
65
+ path: @path_parser&.unpack(route_params),
66
+ query: parse_query(request.env[Rack::QUERY_STRING]),
67
+ headers: @headers_parser&.unpack_env(request.env),
68
+ cookies: @cookies_parser&.unpack(request.env[Rack::HTTP_COOKIE]),
69
+ body: @body_parsers&.call(request)
70
+ )
71
+ end
72
+
73
+ def parse_query(query_string)
74
+ @query_parser&.unpack(query_string)
75
+ rescue OpenapiParameters::InvalidParameterError
76
+ Failure.fail!(:invalid_query, message: 'Invalid query parameter.')
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ # @!visibility private
5
+ module RequestBodyParsers
6
+ DEFAULT = ->(request) { Utils.read_body(request) }
7
+
8
+ @parsers = {}
9
+
10
+ class << self
11
+ attr_reader :parsers
12
+
13
+ def register(pattern, parser)
14
+ parsers[pattern] = parser
15
+ end
16
+
17
+ def [](content_type)
18
+ key = parsers.keys.find { content_type.match?(_1) }
19
+ parsers.fetch(key) { DEFAULT }
20
+ end
21
+ end
22
+
23
+ # Not sure where to put this
24
+ module Utils
25
+ def self.read_body(request)
26
+ body = request.body&.read
27
+ request.body.rewind if request.body.respond_to?(:rewind)
28
+ body
29
+ end
30
+ end
31
+
32
+ register(/json/i, lambda do |request|
33
+ body = Utils.read_body(request)
34
+ JSON.parse(body) unless body.nil? || body.empty?
35
+ rescue JSON::ParserError
36
+ Failure.fail!(:invalid_body, message: 'Failed to parse request body as JSON')
37
+ end)
38
+
39
+ # Parses multipart/form-data requests and currently puts the contents of a file upload at the parsed hash values.
40
+ # NOTE: This behavior will probably change in the next major version.
41
+ # The uploaded file should not be read during request validation.
42
+ module MultipartBodyParser
43
+ def self.call(request)
44
+ request.POST.transform_values do |value|
45
+ unpack_value(value)
46
+ end
47
+ end
48
+
49
+ def self.unpack_value(value)
50
+ return value.map { unpack_value(_1) } if value.is_a?(Array)
51
+ return value unless value.is_a?(Hash)
52
+ return value[:tempfile]&.read if value.key?(:tempfile)
53
+
54
+ value.transform_values do |v|
55
+ unpack_value(v)
56
+ end
57
+ end
58
+ end
59
+
60
+ register('multipart/form-data', MultipartBodyParser)
61
+
62
+ register('application/x-www-form-urlencoded', lambda(&:POST))
63
+ end
64
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'failure'
4
+ require_relative 'validators/request_parameters'
5
+ require_relative 'validators/request_body'
6
+
7
+ module OpenapiFirst
8
+ # Validates a Request against a request definition.
9
+ class RequestValidator
10
+ def initialize(
11
+ content_schema:,
12
+ required_request_body:,
13
+ path_schema:,
14
+ query_schema:,
15
+ header_schema:,
16
+ cookie_schema:
17
+ )
18
+ @validators = []
19
+ @validators << Validators::RequestBody.new(content_schema:, required_request_body:) if content_schema
20
+ @validators.concat Validators::RequestParameters.for(
21
+ path_schema:,
22
+ query_schema:,
23
+ header_schema:,
24
+ cookie_schema:
25
+ )
26
+ end
27
+
28
+ def call(parsed_request)
29
+ @validators.each { |v| v.call(parsed_request) }
30
+ end
31
+ end
32
+ end