request_handler 1.1.0 → 2.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +75 -0
  3. data/CHANGELOG.md +35 -0
  4. data/CODE_OF_CONDUCT.md +69 -0
  5. data/Gemfile +3 -0
  6. data/{LICENSE.txt → LICENSE} +0 -0
  7. data/README.md +241 -45
  8. data/lib/request_handler.rb +8 -16
  9. data/lib/request_handler/base.rb +44 -35
  10. data/lib/request_handler/base_parser.rb +9 -0
  11. data/lib/request_handler/builder/base.rb +23 -0
  12. data/lib/request_handler/builder/body_builder.rb +27 -0
  13. data/lib/request_handler/builder/fieldsets_builder.rb +28 -0
  14. data/lib/request_handler/builder/fieldsets_resource_builder.rb +17 -0
  15. data/lib/request_handler/builder/filter_builder.rb +31 -0
  16. data/lib/request_handler/builder/headers_builder.rb +23 -0
  17. data/lib/request_handler/builder/include_options_builder.rb +23 -0
  18. data/lib/request_handler/builder/multipart_builder.rb +22 -0
  19. data/lib/request_handler/builder/multipart_resource_builder.rb +35 -0
  20. data/lib/request_handler/builder/options_builder.rb +97 -0
  21. data/lib/request_handler/builder/page_builder.rb +30 -0
  22. data/lib/request_handler/builder/page_resource_builder.rb +23 -0
  23. data/lib/request_handler/builder/query_builder.rb +23 -0
  24. data/lib/request_handler/builder/sort_options_builder.rb +23 -0
  25. data/lib/request_handler/concerns/config_helper.rb +25 -0
  26. data/lib/request_handler/config.rb +33 -0
  27. data/lib/request_handler/error.rb +14 -3
  28. data/lib/request_handler/fieldsets_parser.rb +35 -11
  29. data/lib/request_handler/filter_parser.rb +25 -1
  30. data/lib/request_handler/header_parser.rb +30 -3
  31. data/lib/request_handler/include_option_parser.rb +19 -6
  32. data/lib/request_handler/json_api_document_parser.rb +15 -1
  33. data/lib/request_handler/multipart_parser.rb +25 -17
  34. data/lib/request_handler/option_parser.rb +3 -3
  35. data/lib/request_handler/page_parser.rb +33 -17
  36. data/lib/request_handler/query_parser.rb +8 -0
  37. data/lib/request_handler/schema_parser.rb +41 -21
  38. data/lib/request_handler/sort_option_parser.rb +18 -7
  39. data/lib/request_handler/validation/definition_engine.rb +35 -0
  40. data/lib/request_handler/validation/dry_engine.rb +58 -0
  41. data/lib/request_handler/validation/engine.rb +32 -0
  42. data/lib/request_handler/validation/errors.rb +5 -0
  43. data/lib/request_handler/validation/result.rb +17 -0
  44. data/lib/request_handler/version.rb +1 -1
  45. data/request_handler.gemspec +9 -8
  46. metadata +66 -42
  47. data/.travis.yml +0 -40
@@ -7,24 +7,37 @@ module RequestHandler
7
7
  def run
8
8
  return [] unless params.key?('include')
9
9
  options = fetch_options
10
- raise IncludeParamsError, include: 'must not contain a space' if options.include? ' '
10
+ raise_error('INVALID_QUERY_PARAMETER', 'must not contain a space') if options.include?(' ')
11
11
  allowed_options(options.split(','))
12
12
  end
13
13
 
14
14
  def allowed_options(options)
15
15
  options.map do |option|
16
- option.gsub!('.', ::RequestHandler.separator)
16
+ option.gsub!('.', ::RequestHandler.configuration.separator)
17
17
  begin
18
- allowed_options_type.call(option).to_sym
19
- rescue Dry::Types::ConstraintError
20
- raise OptionNotAllowedError, option.to_sym => 'is not an allowed include option'
18
+ RequestHandler.configuration.validation_engine.validate!(option, allowed_options_type).output.to_sym
19
+ rescue Validation::Error
20
+ raise_error('OPTION_NOT_ALLOWED', "#{option} is not an allowed include option", OptionNotAllowedError)
21
21
  end
22
22
  end
23
23
  end
24
24
 
25
25
  def fetch_options
26
- raise IncludeParamsError, include_options: 'query paramter must not be empty' if empty_param?('include')
26
+ raise_error('INVALID_QUERY_PARAMETER', 'must not be empty') if empty_param?('include')
27
27
  params.fetch('include') { '' }
28
28
  end
29
+
30
+ private
31
+
32
+ def raise_error(code, detail, error_klass = IncludeParamsError)
33
+ raise error_klass, [
34
+ {
35
+ status: '400',
36
+ code: code,
37
+ detail: detail,
38
+ source: { parameter: 'include' }
39
+ }
40
+ ]
41
+ end
29
42
  end
30
43
  end
@@ -4,6 +4,8 @@ require 'request_handler/schema_parser'
4
4
  require 'request_handler/error'
5
5
  module RequestHandler
6
6
  class JsonApiDocumentParser < SchemaParser
7
+ NON_ATTRIBUTE_MEMBERS = %i[id type meta links].freeze
8
+
7
9
  def initialize(document:, schema:, schema_options: {})
8
10
  raise MissingArgumentError, "data": 'is missing' if document.nil?
9
11
  super(schema: schema, schema_options: schema_options)
@@ -19,7 +21,11 @@ module RequestHandler
19
21
 
20
22
  def flattened_document
21
23
  resource = document.fetch('data') do
22
- raise BodyParamsError, resource: 'must contain data'
24
+ raise BodyParamsError, [{ code: 'INVALID_JSON_API',
25
+ status: '400',
26
+ title: 'Body is not valid JSON API payload',
27
+ detail: "Member 'data' is missing",
28
+ source: { pointer: '/' } }]
23
29
  end
24
30
  flatten_resource!(resource)
25
31
  end
@@ -37,6 +43,14 @@ module RequestHandler
37
43
  end
38
44
  end
39
45
 
46
+ def build_pointer(error)
47
+ non_nested_identifier = error[:schema_pointer] == error[:element].to_s
48
+ non_attribute_member = NON_ATTRIBUTE_MEMBERS.include?(error[:element])
49
+ ['/data',
50
+ ('attributes' unless non_attribute_member && non_nested_identifier),
51
+ error[:schema_pointer]].compact.join('/')
52
+ end
53
+
40
54
  attr_reader :document
41
55
  end
42
56
  end
@@ -1,33 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'request_handler/error'
4
+ require 'request_handler/base_parser'
4
5
  require 'request_handler/schema_parser'
5
6
  require 'request_handler/document_parser'
6
7
 
7
8
  module RequestHandler
8
- class MultipartsParser
9
+ class MultipartsParser < BaseParser
9
10
  def initialize(request:, multipart_config:)
10
11
  @request = request
11
12
  @params = request.params
12
13
  @multipart_config = multipart_config
13
- missing_arguments = []
14
- missing_arguments << { multipart_config: 'is missing' } if multipart_config.nil?
15
- raise MissingArgumentError, missing_arguments unless missing_arguments.empty?
14
+ raise MissingArgumentError, [{ multipart_config: 'is missing' }] if multipart_config.nil?
16
15
  end
17
16
 
18
17
  def run
19
- multipart_config.each_with_object({}) do |(name, config), memo|
20
- raise MultipartParamsError, multipart: "#{name} missing" if config[:required] && !params.key?(name.to_s)
18
+ deep_to_h(multipart_config).each_with_object({}) do |(name, config), indexed_parts|
19
+ validate_presence!(name) if config[:required]
21
20
  next if params[name.to_s].nil?
22
- memo[name] = parse_part(name.to_s)
21
+ indexed_parts[name] = parse_part(name.to_s)
23
22
  end
24
23
  end
25
24
 
26
25
  private
27
26
 
27
+ def validate_presence!(sidecar_name)
28
+ return if params.key?(sidecar_name.to_s)
29
+ raise multipart_params_error("missing required sidecar resource: #{sidecar_name}")
30
+ end
31
+
32
+ def multipart_params_error(detail = '')
33
+ MultipartParamsError.new([{
34
+ status: '400',
35
+ code: 'INVALID_MULTIPART_REQUEST',
36
+ detail: detail
37
+ }])
38
+ end
39
+
28
40
  def parse_part(name)
29
- params[name].fetch(:tempfile) { raise MultipartParamsError, multipart_file: 'missing' }
30
- if lookup("#{name}.schema")
41
+ params[name].fetch(:tempfile) { raise MultipartParamsError, [{ multipart_file: 'missing' }] }
42
+ if lookup(multipart_config, "#{name}.schema")
31
43
  parse_data(name)
32
44
  else
33
45
  params[name]
@@ -36,12 +48,12 @@ module RequestHandler
36
48
 
37
49
  def parse_data(name)
38
50
  data = load_json(name)
39
- type = lookup("#{name}.type")
51
+ type = lookup(multipart_config, "#{name}.type")
40
52
  DocumentParser.new(
41
53
  type: type,
42
54
  document: data,
43
- schema: lookup("#{name}.schema"),
44
- schema_options: execute_options(lookup("#{name}.options"))
55
+ schema: lookup(multipart_config, "#{name}.schema"),
56
+ schema_options: execute_options(lookup(multipart_config, "#{name}.options"))
45
57
  ).run
46
58
  end
47
59
 
@@ -51,17 +63,13 @@ module RequestHandler
51
63
  file = file.read
52
64
  MultiJson.load(file)
53
65
  rescue MultiJson::ParseError
54
- raise MultipartParamsError, multipart_file: 'invalid JSON'
66
+ raise multipart_params_error('sidecar resource is not valid JSON')
55
67
  end
56
68
 
57
69
  def multipart_file(name)
58
70
  params[name][:tempfile]
59
71
  end
60
72
 
61
- def lookup(key)
62
- multipart_config.lookup!(key)
63
- end
64
-
65
73
  def execute_options(options)
66
74
  return {} if options.nil?
67
75
  return options unless options.respond_to?(:call)
@@ -6,13 +6,13 @@ module RequestHandler
6
6
  def initialize(params:, allowed_options_type:)
7
7
  @params = params
8
8
  @allowed_options_type = allowed_options_type
9
- raise InternalArgumentError, allowed_options_type: 'must be a Enum' unless enum?
9
+ raise InternalArgumentError, allowed_options_type: 'must be a Schema' unless schema?
10
10
  end
11
11
 
12
12
  private
13
13
 
14
- def enum?
15
- @allowed_options_type.class.equal?(Dry::Types::Enum)
14
+ def schema?
15
+ RequestHandler.configuration.validation_engine.valid_schema?(@allowed_options_type)
16
16
  end
17
17
 
18
18
  def empty_param?(param)
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'request_handler/error'
4
+ require 'request_handler/base_parser'
5
+
4
6
  module RequestHandler
5
- class PageParser
7
+ class PageParser < BaseParser
6
8
  def initialize(params:, page_config:)
7
9
  missing_arguments = []
8
10
  missing_arguments << { params: 'is missing' } if params.nil?
@@ -14,11 +16,10 @@ module RequestHandler
14
16
  end
15
17
 
16
18
  def run
17
- base = { number: extract_number, size: extract_size }
18
- cfg = config.keys.reduce(base) do |memo, key|
19
+ cfg = deep_to_h(config).keys.reduce(base_page) do |memo, key|
19
20
  next memo if TOP_LEVEL_PAGE_KEYS.include?(key)
20
- memo.merge!("#{key}#{::RequestHandler.separator}number".to_sym => extract_number(prefix: key),
21
- "#{key}#{::RequestHandler.separator}size".to_sym => extract_size(prefix: key))
21
+ memo.merge!("#{key}#{separator}number".to_sym => extract_number(prefix: key),
22
+ "#{key}#{separator}size".to_sym => extract_size(prefix: key))
22
23
  end
23
24
  check_for_missing_options(cfg)
24
25
  cfg
@@ -29,17 +30,20 @@ module RequestHandler
29
30
  TOP_LEVEL_PAGE_KEYS = Set.new(%i[default_size max_size])
30
31
  attr_reader :page_options, :config
31
32
 
33
+ def base_page
34
+ { number: extract_number, size: extract_size }
35
+ end
36
+
32
37
  def check_for_missing_options(config)
33
38
  missing_arguments = page_options.keys - config.keys.map(&:to_s)
34
39
  return if missing_arguments.empty?
35
- missing_arguments.map! { |e| e.gsub(::RequestHandler.separator, '.') }
40
+ missing_arguments.map! { |e| e.gsub(separator, '.') }
36
41
  warn 'client sent unknown option ' + missing_arguments.to_s unless missing_arguments.empty?
37
42
  end
38
43
 
39
44
  def extract_number(prefix: nil)
40
45
  number_string = lookup_nested_params_key('number', prefix) || 1
41
- error_msg = { :"#{prefix}#{::RequestHandler.separator}number" => 'must be a positive Integer' }
42
- check_int(string: number_string, error_msg: error_msg)
46
+ check_int(string: number_string, param: "#{prefix}.number")
43
47
  end
44
48
 
45
49
  def extract_size(prefix: nil)
@@ -59,16 +63,24 @@ module RequestHandler
59
63
  def fetch_and_check_size(prefix)
60
64
  size_string = lookup_nested_params_key('size', prefix)
61
65
  return nil if size_string.nil?
62
- error_msg = { :"#{prefix}#{::RequestHandler.separator}size" => 'must be a positive Integer' }
63
- check_int(string: size_string, error_msg: error_msg) unless size_string.nil?
66
+ check_int(string: size_string, param: "#{prefix}.size") unless size_string.nil?
64
67
  end
65
68
 
66
- def check_int(string:, error_msg:)
69
+ def check_int(string:, param:)
67
70
  output = Integer(string)
68
- raise PageParamsError, error_msg unless output > 0
71
+ raise_page_param_error!(param) unless output > 0
69
72
  output
70
73
  rescue ArgumentError
71
- raise PageParamsError, error_msg
74
+ raise_page_param_error!(param)
75
+ end
76
+
77
+ def raise_page_param_error!(param)
78
+ raise PageParamsError, [{
79
+ code: 'INVALID_QUERY_PARAMETER',
80
+ status: '400',
81
+ detail: 'must be a positive integer',
82
+ source: { parameter: "page[#{param}]" }
83
+ }]
72
84
  end
73
85
 
74
86
  def apply_max_size_constraint(size, prefix)
@@ -86,11 +98,11 @@ module RequestHandler
86
98
 
87
99
  def lookup_nested_config_key(key, prefix)
88
100
  key = prefix ? "#{prefix}.#{key}" : key
89
- config.lookup!(key)
101
+ lookup(config, key)
90
102
  end
91
103
 
92
104
  def lookup_nested_params_key(key, prefix)
93
- key = prefix ? "#{prefix}#{::RequestHandler.separator}#{key}" : key
105
+ key = prefix ? "#{prefix}#{separator}#{key}" : key
94
106
  page_options.fetch(key, nil)
95
107
  end
96
108
 
@@ -98,12 +110,16 @@ module RequestHandler
98
110
  ::RequestHandler.configuration.logger.warn(message)
99
111
  end
100
112
 
101
- def raise_no_default_size(prefix, sep = ::RequestHandler.separator)
113
+ def raise_no_default_size(prefix, sep = separator)
102
114
  raise NoConfigAvailableError, :"#{prefix}#{sep}size" => 'has no default_size'
103
115
  end
104
116
 
105
- def raise_not_positive(prefix, key, sep = ::RequestHandler.separator)
117
+ def raise_not_positive(prefix, key, sep = separator)
106
118
  raise InternalArgumentError, :"#{prefix}#{sep}#{key}" => 'must be a positive Integer'
107
119
  end
120
+
121
+ def separator
122
+ ::RequestHandler.configuration.separator
123
+ end
108
124
  end
109
125
  end
@@ -14,6 +14,14 @@ module RequestHandler
14
14
 
15
15
  def run
16
16
  validate_schema(query)
17
+ rescue SchemaValidationError => e
18
+ raise ExternalArgumentError, (e.errors.map do |schema_error|
19
+ param = schema_error[:source][:pointer]
20
+ { status: '400',
21
+ code: "#{query[param] ? 'INVALID' : 'MISSING'}_QUERY_PARAMETER",
22
+ detail: schema_error[:detail],
23
+ source: { parameter: param } }
24
+ end)
17
25
  end
18
26
 
19
27
  private
@@ -8,7 +8,7 @@ module RequestHandler
8
8
  missing_arguments << { schema: 'is missing' } if schema.nil?
9
9
  missing_arguments << { schema_options: 'is missing' } if schema_options.nil?
10
10
  raise MissingArgumentError, missing_arguments unless missing_arguments.empty?
11
- raise InternalArgumentError, schema: 'must be a Schema' unless schema.is_a?(Dry::Validation::Schema)
11
+ raise InternalArgumentError, schema: 'must be a Schema' unless validation_engine.valid_schema?(schema)
12
12
  @schema = schema
13
13
  @schema_options = schema_options
14
14
  end
@@ -17,28 +17,57 @@ module RequestHandler
17
17
 
18
18
  def validate_schema(data, with: schema)
19
19
  raise MissingArgumentError, data: 'is missing' if data.nil?
20
- data = deep_symbolize_keys(data) if with.rules.keys.first.is_a?(Symbol)
21
20
  validator = validate(data, schema: with)
22
21
  validation_failure?(validator)
23
22
  validator.output
24
23
  end
25
24
 
26
25
  def validate(data, schema:)
27
- if schema_options.empty?
28
- schema.call(data)
29
- else
30
- schema.with(schema_options).call(data)
31
- end
26
+ validation_engine.validate(data, schema, options: schema_options)
32
27
  end
33
28
 
34
29
  def validation_failure?(validator)
35
- return unless validator.failure?
36
- errors = validator.errors.each_with_object({}) do |(k, v), memo|
37
- add_note(v, k, memo)
30
+ return if validator.valid?
31
+
32
+ errors = build_errors(validator.errors).map do |error|
33
+ jsonapi_error(error)
38
34
  end
39
35
  raise SchemaValidationError, errors
40
36
  end
41
37
 
38
+ def build_errors(error_hash, path = [])
39
+ errors = []
40
+ error_hash.each do |k, v|
41
+ errors += build_errors(v, path << k).flatten if v.is_a?(Hash)
42
+ v.each { |error| errors << error(path, k, error) } if v.is_a?(Array)
43
+ errors << error(path, k, v) if v.is_a?(String)
44
+ end
45
+ errors
46
+ end
47
+
48
+ def error(path, element, failure)
49
+ schema_pointer = validation_engine.error_pointer(failure) || (path + [element]).join('/')
50
+ {
51
+ schema_pointer: schema_pointer,
52
+ element: element,
53
+ message: validation_engine.error_message(failure)
54
+ }
55
+ end
56
+
57
+ def jsonapi_error(error)
58
+ {
59
+ status: '422',
60
+ code: 'INVALID_RESOURCE_SCHEMA',
61
+ title: 'Invalid resource',
62
+ detail: error[:message],
63
+ source: { pointer: build_pointer(error) }
64
+ }
65
+ end
66
+
67
+ def build_pointer(error)
68
+ error[:schema_pointer]
69
+ end
70
+
42
71
  def add_note(v, k, memo)
43
72
  memo[k] = if v.is_a? Array
44
73
  v.join(' ')
@@ -48,17 +77,8 @@ module RequestHandler
48
77
  memo
49
78
  end
50
79
 
51
- def deep_symbolize_keys(hash)
52
- mem = {}
53
- hash.map do |key, value|
54
- if value.is_a?(Hash)
55
- value = deep_symbolize_keys(value)
56
- elsif value.is_a?(Array)
57
- value.map! { |v| v.is_a?(Hash) ? deep_symbolize_keys(v) : v }
58
- end
59
- mem[key.to_sym] = value
60
- end
61
- mem
80
+ def validation_engine
81
+ RequestHandler.configuration.validation_engine
62
82
  end
63
83
 
64
84
  attr_reader :schema, :schema_options
@@ -8,26 +8,26 @@ module RequestHandler
8
8
  def run
9
9
  return [] unless params.key?('sort')
10
10
  sort_options = parse_options(fetch_options)
11
- raise SortParamsError, sort_options: 'must be unique' if duplicates?(sort_options)
11
+ raise SortParamsError, [jsonapi_error('sort options must be unique')] if duplicates?(sort_options)
12
12
  sort_options
13
13
  end
14
14
 
15
15
  def fetch_options
16
- raise SortParamsError, sort_options: 'the query paramter must not be empty' if empty_param?('sort')
16
+ raise SortParamsError, [jsonapi_error('must not be empty')] if empty_param?('sort')
17
17
  params.fetch('sort') { '' }.split(',')
18
18
  end
19
19
 
20
20
  def parse_options(options)
21
21
  options.map do |option|
22
22
  name, order = parse_option(option)
23
- name.gsub!('.', ::RequestHandler.separator)
23
+ name.gsub!('.', ::RequestHandler.configuration.separator)
24
24
  allowed_option(name)
25
25
  SortOption.new(name, order)
26
26
  end
27
27
  end
28
28
 
29
29
  def parse_option(option)
30
- raise SortParamsError, sort_options: 'must not contain a space' if option.include? ' '
30
+ raise SortParamsError, [jsonapi_error('must not contain spaces')] if option.include? ' '
31
31
  if option.start_with?('-')
32
32
  [option[1..-1], :desc]
33
33
  else
@@ -36,13 +36,24 @@ module RequestHandler
36
36
  end
37
37
 
38
38
  def allowed_option(name)
39
- allowed_options_type.call(name)
40
- rescue Dry::Types::ConstraintError
41
- raise OptionNotAllowedError, name.to_sym => 'is not an allowed sort option'
39
+ RequestHandler.configuration.validation_engine.validate!(name, allowed_options_type).output
40
+ rescue Validation::Error
41
+ raise OptionNotAllowedError, [jsonapi_error("#{name} is not an allowed sort option")]
42
42
  end
43
43
 
44
44
  def duplicates?(options)
45
45
  !options.uniq!(&:field).nil?
46
46
  end
47
+
48
+ private
49
+
50
+ def jsonapi_error(detail)
51
+ {
52
+ code: 'INVALID_QUERY_PARAMETER',
53
+ status: '400',
54
+ source: { parameter: 'sort' },
55
+ detail: detail
56
+ }
57
+ end
47
58
  end
48
59
  end