request_handler 1.1.0 → 2.1.1

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 (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