openapi_first 2.1.1 → 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -11,82 +11,43 @@ module OpenapiFirst
11
11
  # This class represents one of those requests.
12
12
  class Request
13
13
  def initialize(path:, request_method:, operation_object:,
14
- parameters:, content_type:, content_schema:, required_body:,
15
- hooks:, openapi_version:)
14
+ parameters:, content_type:, content_schema:, required_body:)
16
15
  @path = path
17
16
  @request_method = request_method
18
17
  @content_type = content_type
19
18
  @content_schema = content_schema
20
- @required_request_body = required_body == true
21
19
  @operation = operation_object
22
- @parameters = build_parameters(parameters)
23
20
  @request_parser = RequestParser.new(
24
- query_parameters: @parameters[:query],
25
- path_parameters: @parameters[:path],
26
- header_parameters: @parameters[:header],
27
- cookie_parameters: @parameters[:cookie],
21
+ query_parameters: parameters.query,
22
+ path_parameters: parameters.path,
23
+ header_parameters: parameters.header,
24
+ cookie_parameters: parameters.cookie,
28
25
  content_type:
29
26
  )
30
- @validator = RequestValidator.new(self, hooks:, openapi_version:)
27
+ @validator = RequestValidator.new(
28
+ content_schema:,
29
+ required_request_body: required_body == true,
30
+ path_schema: parameters.path_schema,
31
+ query_schema: parameters.query_schema,
32
+ header_schema: parameters.header_schema,
33
+ cookie_schema: parameters.cookie_schema
34
+ )
31
35
  end
32
36
 
33
37
  attr_reader :content_type, :content_schema, :operation, :request_method, :path
34
38
 
35
39
  def validate(request, route_params:)
36
- parsed_values = {}
40
+ parsed_request = nil
37
41
  error = catch FAILURE do
38
- parsed_values = @request_parser.parse(request, route_params:)
39
- @validator.call(parsed_values)
42
+ parsed_request = @request_parser.parse(request, route_params:)
43
+ @validator.call(parsed_request)
40
44
  nil
41
45
  end
42
- ValidatedRequest.new(request, parsed_values:, error:, request_definition: self)
43
- end
44
-
45
- # These return a Schema instance for each type of parameters
46
- %i[path query header cookie].each do |location|
47
- define_method(:"#{location}_schema") do
48
- build_parameters_schema(@parameters[location])
49
- end
50
- end
51
-
52
- def required_request_body?
53
- @required_request_body
46
+ ValidatedRequest.new(request, parsed_request:, error:, request_definition: self)
54
47
  end
55
48
 
56
49
  def operation_id
57
50
  @operation['operationId']
58
51
  end
59
-
60
- private
61
-
62
- IGNORED_HEADERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
63
- private_constant :IGNORED_HEADERS
64
-
65
- def build_parameters(parameter_definitions)
66
- result = {}
67
- parameter_definitions&.each do |parameter|
68
- (result[parameter['in'].to_sym] ||= []) << parameter
69
- end
70
- result[:header]&.reject! { IGNORED_HEADERS.include?(_1['name']) }
71
- result
72
- end
73
-
74
- def build_parameters_schema(parameters)
75
- return unless parameters
76
-
77
- properties = {}
78
- required = []
79
- parameters.each do |parameter|
80
- schema = parameter['schema']
81
- name = parameter['name']
82
- properties[name] = schema if schema
83
- required << name if parameter['required']
84
- end
85
-
86
- {
87
- 'properties' => properties,
88
- 'required' => required
89
- }
90
- end
91
52
  end
92
53
  end
@@ -0,0 +1,47 @@
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
+ register('multipart/form-data', lambda { |request|
40
+ request.POST.transform_values do |value|
41
+ value.is_a?(Hash) && value[:tempfile] ? value[:tempfile].read : value
42
+ end
43
+ })
44
+
45
+ register('application/x-www-form-urlencoded', lambda(&:POST))
46
+ end
47
+ end
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'openapi_parameters'
4
- require_relative 'body_parser'
4
+ require_relative 'request_body_parsers'
5
5
 
6
6
  module OpenapiFirst
7
+ ParsedRequest = Data.define(:path, :query, :headers, :body, :cookies)
8
+
7
9
  # Parse a request
8
10
  class RequestParser
9
11
  def initialize(
@@ -17,19 +19,19 @@ module OpenapiFirst
17
19
  @path_parser = OpenapiParameters::Path.new(path_parameters) if path_parameters
18
20
  @headers_parser = OpenapiParameters::Header.new(header_parameters) if header_parameters
19
21
  @cookies_parser = OpenapiParameters::Cookie.new(cookie_parameters) if cookie_parameters
20
- @body_parser = BodyParser.new(content_type) if content_type
22
+ @body_parsers = RequestBodyParsers[content_type] if content_type
21
23
  end
22
24
 
23
25
  attr_reader :query, :path, :headers, :cookies
24
26
 
25
27
  def parse(request, route_params:)
26
- result = {}
27
- result[:path] = @path_parser.unpack(route_params) if @path_parser
28
- result[:query] = @query_parser.unpack(request.env[Rack::QUERY_STRING]) if @query_parser
29
- result[:headers] = @headers_parser.unpack_env(request.env) if @headers_parser
30
- result[:cookies] = @cookies_parser.unpack(request.env[Rack::HTTP_COOKIE]) if @cookies_parser
31
- result[:body] = @body_parser.parse(request) if @body_parser
32
- result
28
+ ParsedRequest.new(
29
+ path: @path_parser&.unpack(route_params),
30
+ query: @query_parser&.unpack(request.env[Rack::QUERY_STRING]),
31
+ headers: @headers_parser&.unpack_env(request.env),
32
+ cookies: @cookies_parser&.unpack(request.env[Rack::HTTP_COOKIE]),
33
+ body: @body_parsers&.call(request)
34
+ )
33
35
  end
34
36
  end
35
37
  end
@@ -7,15 +7,22 @@ require_relative 'validators/request_body'
7
7
  module OpenapiFirst
8
8
  # Validates a Request against a request definition.
9
9
  class RequestValidator
10
- VALIDATORS = [
11
- Validators::RequestParameters,
12
- Validators::RequestBody
13
- ].freeze
14
-
15
- def initialize(request_definition, openapi_version:, hooks: {})
16
- @validators = VALIDATORS.filter_map do |klass|
17
- klass.for(request_definition, hooks:, openapi_version:)
18
- end
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
+ )
19
26
  end
20
27
 
21
28
  def call(parsed_request)
@@ -9,14 +9,14 @@ module OpenapiFirst
9
9
  # This is not a direct reflecton of the OpenAPI 3.X response definition, but a combination of
10
10
  # status, content type and content schema.
11
11
  class Response
12
- def initialize(status:, headers:, content_type:, content_schema:, openapi_version:)
12
+ def initialize(status:, headers:, headers_schema:, content_type:, content_schema:)
13
13
  @status = status
14
14
  @content_type = content_type
15
15
  @content_schema = content_schema
16
16
  @headers = headers
17
- @headers_schema = build_headers_schema(headers)
17
+ @headers_schema = headers_schema
18
18
  @parser = ResponseParser.new(headers:, content_type:)
19
- @validator = ResponseValidator.new(self, openapi_version:)
19
+ @validator = ResponseValidator.new(self)
20
20
  end
21
21
 
22
22
  # @attr_reader [Integer] status The HTTP status code of the response definition.
@@ -35,23 +35,5 @@ module OpenapiFirst
35
35
  def parse(request)
36
36
  @parser.parse(request)
37
37
  end
38
-
39
- def build_headers_schema(headers_object)
40
- return unless headers_object&.any?
41
-
42
- properties = {}
43
- required = []
44
- headers_object.each do |name, header|
45
- schema = header['schema']
46
- next if name.casecmp('content-type').zero?
47
-
48
- properties[name] = schema if schema
49
- required << name if header['required']
50
- end
51
- {
52
- 'properties' => properties,
53
- 'required' => required
54
- }
55
- end
56
38
  end
57
39
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ # @visibility private
5
+ module ResponseBodyParsers
6
+ DEFAULT = ->(body) { body }
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
+ register(/json/i, lambda do |body|
24
+ JSON.parse(body)
25
+ rescue JSON::ParserError
26
+ Failure.fail!(:invalid_response_body, message: 'Response body is invalid: Failed to parse response body as JSON')
27
+ end)
28
+ end
29
+ end
@@ -1,40 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'response_body_parsers'
4
+
3
5
  module OpenapiFirst
4
6
  ParsedResponse = Data.define(:body, :headers)
5
7
 
6
8
  # Parse a response
7
9
  class ResponseParser
8
10
  def initialize(headers:, content_type:)
9
- @headers = headers
10
- @json = /json/i.match?(content_type)
11
+ @headers_parser = build_headers_parser(headers)
12
+ @body_parser = ResponseBodyParsers[content_type]
11
13
  end
12
14
 
13
15
  def parse(rack_response)
14
16
  ParsedResponse.new(
15
- body: parse_body(read_body(rack_response)),
16
- headers: parse_headers(rack_response)
17
+ body: @body_parser.call(read_body(rack_response)),
18
+ headers: @headers_parser.call(rack_response.headers)
17
19
  )
18
20
  end
19
21
 
20
22
  private
21
23
 
22
- attr_reader :headers
23
-
24
- def json? = @json
25
-
26
- def parse_body(body)
27
- return parse_json(body) if json?
28
-
29
- body
30
- end
31
-
32
- def parse_json(body)
33
- MultiJson.load(body)
34
- rescue MultiJson::ParseError
35
- Failure.fail!(:invalid_response_body, message: 'Response body is invalid: Failed to parse response body as JSON')
36
- end
37
-
38
24
  def read_body(rack_response)
39
25
  buffered_body = +''
40
26
  if rack_response.body.respond_to?(:each)
@@ -44,14 +30,11 @@ module OpenapiFirst
44
30
  rack_response.body
45
31
  end
46
32
 
47
- def parse_headers(rack_response)
48
- return {} if headers.nil?
49
-
50
- # TODO: memoize unpacker
51
- headers_as_parameters = headers.map do |name, definition|
33
+ def build_headers_parser(header_definitions)
34
+ headers_as_parameters = header_definitions.to_a.map do |name, definition|
52
35
  definition.merge('name' => name, 'in' => 'header')
53
36
  end
54
- OpenapiParameters::Header.new(headers_as_parameters).unpack(rack_response.headers)
37
+ OpenapiParameters::Header.new(headers_as_parameters).method(:unpack)
55
38
  end
56
39
  end
57
40
  end
@@ -11,9 +11,9 @@ module OpenapiFirst
11
11
  Validators::ResponseBody
12
12
  ].freeze
13
13
 
14
- def initialize(response_definition, openapi_version:)
14
+ def initialize(response_definition)
15
15
  @validators = VALIDATORS.filter_map do |klass|
16
- klass.for(response_definition, openapi_version:)
16
+ klass.for(response_definition)
17
17
  end
18
18
  end
19
19
 
@@ -8,9 +8,9 @@ module OpenapiFirst
8
8
  class ValidatedRequest < SimpleDelegator
9
9
  extend Forwardable
10
10
 
11
- def initialize(original_request, error:, parsed_values: {}, request_definition: nil)
11
+ def initialize(original_request, error:, parsed_request: nil, request_definition: nil)
12
12
  super(original_request)
13
- @parsed_values = Hash.new({}).merge(parsed_values)
13
+ @parsed_request = parsed_request
14
14
  @error = error
15
15
  @request_definition = request_definition
16
16
  end
@@ -33,57 +33,41 @@ module OpenapiFirst
33
33
 
34
34
  # Parsed path parameters
35
35
  # @return [Hash<String, anything>]
36
- def parsed_path_parameters
37
- @parsed_values[:path]
38
- end
36
+ def parsed_path_parameters = @parsed_request&.path || {}
39
37
 
40
38
  # Parsed query parameters. This only returns the query parameters that are defined in the OpenAPI spec.
41
39
  # @return [Hash<String, anything>]
42
- def parsed_query
43
- @parsed_values[:query]
44
- end
40
+ def parsed_query = @parsed_request&.query || {}
45
41
 
46
42
  # Parsed headers. This only returns the query parameters that are defined in the OpenAPI spec.
47
43
  # @return [Hash<String, anything>]
48
- def parsed_headers
49
- @parsed_values[:headers]
50
- end
44
+ def parsed_headers = @parsed_request&.headers || {}
51
45
 
52
46
  # Parsed cookies. This only returns the query parameters that are defined in the OpenAPI spec.
53
47
  # @return [Hash<String, anything>]
54
- def parsed_cookies
55
- @parsed_values[:cookies]
56
- end
48
+ def parsed_cookies = @parsed_request&.cookies || {}
57
49
 
58
50
  # Parsed body. This parses the body according to the content type.
59
51
  # Note that this returns the hole body, not only the fields that are defined in the OpenAPI spec.
60
52
  # You can use JSON Schemas `additionalProperties` or `unevaluatedProperties` to
61
- # returns a validation error if the body contains unknown fields.
62
- # @return [Hash<String, anything>]
63
- def parsed_body
64
- @parsed_values[:body]
65
- end
53
+ # return a validation error if the body contains unknown fields.
54
+ # @return [Hash<String, anything>, anything]
55
+ def parsed_body = @parsed_request&.body
66
56
 
67
57
  # Checks if the request is valid.
68
- def valid?
69
- error.nil?
70
- end
58
+ def valid? = error.nil?
71
59
 
72
60
  # Checks if the request is invalid.
73
- def invalid?
74
- !valid?
75
- end
61
+ def invalid? = !valid?
76
62
 
77
63
  # Returns true if the request is defined.
78
- def known?
79
- request_definition != nil
80
- end
64
+ def known? = request_definition != nil
81
65
 
82
66
  # Merged path, query, body parameters.
83
67
  # Here path has the highest precedence, then query, then body.
84
68
  # @return [Hash<String, anything>]
85
69
  def parsed_params
86
- @parsed_params ||= parsed_body.merge(parsed_query, parsed_path_parameters)
70
+ @parsed_params ||= parsed_body.to_h.merge(parsed_query, parsed_path_parameters) || {}
87
71
  end
88
72
  end
89
73
  end
@@ -3,37 +3,23 @@
3
3
  module OpenapiFirst
4
4
  module Validators
5
5
  class RequestBody
6
- def self.for(request_definition, openapi_version:, hooks: {})
7
- schema = request_definition.content_schema
8
- return unless schema
9
-
10
- after_property_validation = hooks[:after_request_body_property_validation]
11
-
12
- new(Schema.new(schema, after_property_validation:, openapi_version:),
13
- required: request_definition.required_request_body?)
14
- end
15
-
16
- def initialize(schema, required:)
17
- @schema = schema
18
- @required = required
6
+ def initialize(content_schema:, required_request_body:)
7
+ @schema = content_schema
8
+ @required = required_request_body
19
9
  end
20
10
 
21
- def call(request)
22
- request_body = read_body(request)
23
- if request_body.nil?
11
+ def call(parsed_request)
12
+ body = parsed_request.body
13
+ if body.nil?
24
14
  Failure.fail!(:invalid_body, message: 'Request body is not defined') if @required
25
15
  return
26
16
  end
27
17
 
28
- validation = @schema.validate(request_body)
18
+ validation = Schema::ValidationResult.new(
19
+ @schema.validate(body, access_mode: 'write')
20
+ )
29
21
  Failure.fail!(:invalid_body, errors: validation.errors) if validation.error?
30
22
  end
31
-
32
- private
33
-
34
- def read_body(request)
35
- request[:body]
36
- end
37
23
  end
38
24
  end
39
25
  end
@@ -2,31 +2,35 @@
2
2
 
3
3
  module OpenapiFirst
4
4
  module Validators
5
- class RequestParameters
5
+ module RequestParameters
6
6
  RequestHeaders = Data.define(:schema) do
7
- def call(parsed_values)
8
- validation = schema.validate(parsed_values[:headers])
7
+ def call(parsed_request)
8
+ validation = schema.validate(parsed_request.headers)
9
+ validation = Schema::ValidationResult.new(validation.to_a)
9
10
  Failure.fail!(:invalid_header, errors: validation.errors) if validation.error?
10
11
  end
11
12
  end
12
13
 
13
14
  Path = Data.define(:schema) do
14
- def call(parsed_values)
15
- validation = schema.validate(parsed_values[:path])
15
+ def call(parsed_request)
16
+ validation = schema.validate(parsed_request.path)
17
+ validation = Schema::ValidationResult.new(validation.to_a)
16
18
  Failure.fail!(:invalid_path, errors: validation.errors) if validation.error?
17
19
  end
18
20
  end
19
21
 
20
22
  Query = Data.define(:schema) do
21
- def call(parsed_values)
22
- validation = schema.validate(parsed_values[:query])
23
+ def call(parsed_request)
24
+ validation = schema.validate(parsed_request.query)
25
+ validation = Schema::ValidationResult.new(validation.to_a)
23
26
  Failure.fail!(:invalid_query, errors: validation.errors) if validation.error?
24
27
  end
25
28
  end
26
29
 
27
30
  RequestCookies = Data.define(:schema) do
28
- def call(parsed_values)
29
- validation = schema.validate(parsed_values[:cookies])
31
+ def call(parsed_request)
32
+ validation = schema.validate(parsed_request.cookies)
33
+ validation = Schema::ValidationResult.new(validation.to_a)
30
34
  Failure.fail!(:invalid_cookie, errors: validation.errors) if validation.error?
31
35
  end
32
36
  end
@@ -38,23 +42,11 @@ module OpenapiFirst
38
42
  cookie_schema: RequestCookies
39
43
  }.freeze
40
44
 
41
- def self.for(operation, openapi_version:, hooks: {})
42
- after_property_validation = hooks[:after_request_parameter_property_validation]
43
- validators = VALIDATORS.filter_map do |key, klass|
44
- schema = operation.send(key)
45
- klass.new(Schema.new(schema, after_property_validation:, openapi_version:)) if schema
45
+ def self.for(args)
46
+ VALIDATORS.filter_map do |key, klass|
47
+ schema = args[key]
48
+ klass.new(schema) if schema.value
46
49
  end
47
- return if validators.empty?
48
-
49
- new(validators)
50
- end
51
-
52
- def initialize(validators)
53
- @validators = validators
54
- end
55
-
56
- def call(parsed_values)
57
- @validators.each { |validator| validator.call(parsed_values) }
58
50
  end
59
51
  end
60
52
  end
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../schema/validation_result'
4
+
3
5
  module OpenapiFirst
4
6
  module Validators
5
7
  class ResponseBody
6
- def self.for(response_definition, openapi_version:)
8
+ def self.for(response_definition)
7
9
  schema = response_definition&.content_schema
8
10
  return unless schema
9
11
 
10
- new(Schema.new(schema, write: false, openapi_version:))
12
+ new(schema)
11
13
  end
12
14
 
13
15
  def initialize(schema)
@@ -22,7 +24,9 @@ module OpenapiFirst
22
24
  rescue ParseError => e
23
25
  Failure.fail!(:invalid_response_body, message: e.message)
24
26
  end
25
- validation = schema.validate(parsed_body)
27
+ validation = Schema::ValidationResult.new(
28
+ schema.validate(parsed_body, access_mode: 'read')
29
+ )
26
30
  Failure.fail!(:invalid_response_body, errors: validation.errors) if validation.error?
27
31
  end
28
32
  end
@@ -3,11 +3,11 @@
3
3
  module OpenapiFirst
4
4
  module Validators
5
5
  class ResponseHeaders
6
- def self.for(response_definition, openapi_version:)
6
+ def self.for(response_definition)
7
7
  schema = response_definition&.headers_schema
8
- return unless schema
8
+ return unless schema&.value
9
9
 
10
- new(Schema.new(schema, openapi_version:))
10
+ new(schema)
11
11
  end
12
12
 
13
13
  def initialize(schema)
@@ -17,7 +17,9 @@ module OpenapiFirst
17
17
  attr_reader :schema
18
18
 
19
19
  def call(parsed_request)
20
- validation = schema.validate(parsed_request.headers)
20
+ validation = Schema::ValidationResult.new(
21
+ schema.validate(parsed_request.headers)
22
+ )
21
23
  Failure.fail!(:invalid_response_header, errors: validation.errors) if validation.error?
22
24
  end
23
25
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiFirst
4
- VERSION = '2.1.1'
4
+ VERSION = '2.2.0'
5
5
  end