api_matchers 0.6.1 → 1.0.0

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 (115) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +30 -0
  3. data/.gitignore +1 -1
  4. data/Gemfile +1 -1
  5. data/History.markdown +62 -46
  6. data/README.markdown +1070 -114
  7. data/TODO.markdown +39 -3
  8. data/api_matchers.gemspec +13 -6
  9. data/lib/api_matchers/collection/base.rb +65 -0
  10. data/lib/api_matchers/collection/be_sorted_by.rb +97 -0
  11. data/lib/api_matchers/collection/have_json_size.rb +54 -0
  12. data/lib/api_matchers/core/exceptions.rb +5 -0
  13. data/lib/api_matchers/core/find_in_json.rb +57 -28
  14. data/lib/api_matchers/core/http_status_codes.rb +118 -0
  15. data/lib/api_matchers/core/json_path_finder.rb +87 -0
  16. data/lib/api_matchers/core/parser.rb +4 -2
  17. data/lib/api_matchers/core/rspec_matchers.rb +130 -37
  18. data/lib/api_matchers/core/setup.rb +49 -23
  19. data/lib/api_matchers/core/value_normalizer.rb +26 -0
  20. data/lib/api_matchers/error_response/base.rb +77 -0
  21. data/lib/api_matchers/error_response/have_error.rb +69 -0
  22. data/lib/api_matchers/error_response/have_error_on.rb +173 -0
  23. data/lib/api_matchers/hateoas/base.rb +77 -0
  24. data/lib/api_matchers/hateoas/have_link.rb +102 -0
  25. data/lib/api_matchers/headers/base.rb +7 -7
  26. data/lib/api_matchers/headers/be_json.rb +2 -4
  27. data/lib/api_matchers/headers/be_xml.rb +2 -4
  28. data/lib/api_matchers/headers/have_cache_control.rb +90 -0
  29. data/lib/api_matchers/headers/have_cors_headers.rb +102 -0
  30. data/lib/api_matchers/headers/have_header.rb +102 -0
  31. data/lib/api_matchers/http_status/base.rb +48 -0
  32. data/lib/api_matchers/http_status/be_client_error.rb +17 -0
  33. data/lib/api_matchers/http_status/be_forbidden.rb +17 -0
  34. data/lib/api_matchers/http_status/be_no_content.rb +17 -0
  35. data/lib/api_matchers/http_status/be_not_found.rb +17 -0
  36. data/lib/api_matchers/http_status/be_redirect.rb +17 -0
  37. data/lib/api_matchers/http_status/be_server_error.rb +17 -0
  38. data/lib/api_matchers/http_status/be_successful.rb +17 -0
  39. data/lib/api_matchers/http_status/be_unauthorized.rb +17 -0
  40. data/lib/api_matchers/http_status/be_unprocessable.rb +17 -0
  41. data/lib/api_matchers/http_status/have_http_status.rb +39 -0
  42. data/lib/api_matchers/json_api/base.rb +83 -0
  43. data/lib/api_matchers/json_api/be_json_api_compliant.rb +158 -0
  44. data/lib/api_matchers/json_api/have_json_api_attributes.rb +62 -0
  45. data/lib/api_matchers/json_api/have_json_api_data.rb +110 -0
  46. data/lib/api_matchers/json_api/have_json_api_relationships.rb +62 -0
  47. data/lib/api_matchers/json_structure/base.rb +65 -0
  48. data/lib/api_matchers/json_structure/have_json_keys.rb +55 -0
  49. data/lib/api_matchers/json_structure/have_json_type.rb +72 -0
  50. data/lib/api_matchers/pagination/base.rb +73 -0
  51. data/lib/api_matchers/pagination/be_paginated.rb +73 -0
  52. data/lib/api_matchers/pagination/have_pagination_links.rb +74 -0
  53. data/lib/api_matchers/pagination/have_total_count.rb +77 -0
  54. data/lib/api_matchers/response_body/base.rb +10 -9
  55. data/lib/api_matchers/response_body/have_json.rb +2 -4
  56. data/lib/api_matchers/response_body/have_json_node.rb +45 -1
  57. data/lib/api_matchers/response_body/have_node.rb +2 -0
  58. data/lib/api_matchers/response_body/have_xml_node.rb +13 -14
  59. data/lib/api_matchers/response_body/match_json_schema.rb +89 -0
  60. data/lib/api_matchers/version.rb +3 -1
  61. data/lib/api_matchers.rb +75 -14
  62. data/spec/api_matchers/collection/be_sorted_by_spec.rb +110 -0
  63. data/spec/api_matchers/collection/have_json_size_spec.rb +101 -0
  64. data/spec/api_matchers/error_response/have_error_on_spec.rb +123 -0
  65. data/spec/api_matchers/error_response/have_error_spec.rb +108 -0
  66. data/spec/api_matchers/hateoas/have_link_spec.rb +105 -0
  67. data/spec/api_matchers/headers/base_spec.rb +8 -3
  68. data/spec/api_matchers/headers/be_json_spec.rb +1 -1
  69. data/spec/api_matchers/headers/be_xml_spec.rb +1 -1
  70. data/spec/api_matchers/headers/have_cache_control_spec.rb +102 -0
  71. data/spec/api_matchers/headers/have_cors_headers_spec.rb +74 -0
  72. data/spec/api_matchers/headers/have_header_spec.rb +88 -0
  73. data/spec/api_matchers/http_status/be_client_error_spec.rb +53 -0
  74. data/spec/api_matchers/http_status/be_forbidden_spec.rb +33 -0
  75. data/spec/api_matchers/http_status/be_no_content_spec.rb +33 -0
  76. data/spec/api_matchers/http_status/be_not_found_spec.rb +39 -0
  77. data/spec/api_matchers/http_status/be_redirect_spec.rb +55 -0
  78. data/spec/api_matchers/http_status/be_server_error_spec.rb +49 -0
  79. data/spec/api_matchers/http_status/be_successful_spec.rb +78 -0
  80. data/spec/api_matchers/http_status/be_unauthorized_spec.rb +33 -0
  81. data/spec/api_matchers/http_status/be_unprocessable_spec.rb +39 -0
  82. data/spec/api_matchers/http_status/have_http_status_spec.rb +81 -0
  83. data/spec/api_matchers/json_api/be_json_api_compliant_spec.rb +109 -0
  84. data/spec/api_matchers/json_api/have_json_api_attributes_spec.rb +61 -0
  85. data/spec/api_matchers/json_api/have_json_api_data_spec.rb +95 -0
  86. data/spec/api_matchers/json_api/have_json_api_relationships_spec.rb +61 -0
  87. data/spec/api_matchers/json_structure/have_json_keys_spec.rb +81 -0
  88. data/spec/api_matchers/json_structure/have_json_type_spec.rb +134 -0
  89. data/spec/api_matchers/pagination/be_paginated_spec.rb +95 -0
  90. data/spec/api_matchers/pagination/have_pagination_links_spec.rb +80 -0
  91. data/spec/api_matchers/pagination/have_total_count_spec.rb +85 -0
  92. data/spec/api_matchers/response_body/base_spec.rb +15 -7
  93. data/spec/api_matchers/response_body/have_json_node_spec.rb +57 -0
  94. data/spec/api_matchers/response_body/match_json_schema_spec.rb +86 -0
  95. metadata +154 -48
  96. data/.rvmrc.example +0 -1
  97. data/.travis.yml +0 -12
  98. data/lib/api_matchers/http_status_code/base.rb +0 -32
  99. data/lib/api_matchers/http_status_code/be_bad_request.rb +0 -25
  100. data/lib/api_matchers/http_status_code/be_forbidden.rb +0 -21
  101. data/lib/api_matchers/http_status_code/be_internal_server_error.rb +0 -25
  102. data/lib/api_matchers/http_status_code/be_not_found.rb +0 -25
  103. data/lib/api_matchers/http_status_code/be_ok.rb +0 -25
  104. data/lib/api_matchers/http_status_code/be_unauthorized.rb +0 -25
  105. data/lib/api_matchers/http_status_code/be_unprocessable_entity.rb +0 -25
  106. data/lib/api_matchers/http_status_code/create_resource.rb +0 -25
  107. data/spec/api_matchers/http_status_code/base_spec.rb +0 -12
  108. data/spec/api_matchers/http_status_code/be_bad_request_spec.rb +0 -49
  109. data/spec/api_matchers/http_status_code/be_forbidden_spec.rb +0 -49
  110. data/spec/api_matchers/http_status_code/be_internal_server_error_spec.rb +0 -49
  111. data/spec/api_matchers/http_status_code/be_not_found_spec.rb +0 -49
  112. data/spec/api_matchers/http_status_code/be_ok_spec.rb +0 -49
  113. data/spec/api_matchers/http_status_code/be_unauthorized_spec.rb +0 -49
  114. data/spec/api_matchers/http_status_code/be_unprocessable_entity_spec.rb +0 -27
  115. data/spec/api_matchers/http_status_code/create_resource_spec.rb +0 -49
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module APIMatchers
6
+ module Hateoas
7
+ class Base
8
+ attr_reader :actual
9
+
10
+ def setup
11
+ ::APIMatchers::Core::Setup
12
+ end
13
+
14
+ def matches?(actual)
15
+ @actual = actual
16
+ perform_match
17
+ end
18
+
19
+ def failure_message
20
+ raise NotImplementedError, "Subclasses must implement #failure_message"
21
+ end
22
+
23
+ def failure_message_when_negated
24
+ raise NotImplementedError, "Subclasses must implement #failure_message_when_negated"
25
+ end
26
+
27
+ protected
28
+
29
+ def perform_match
30
+ raise NotImplementedError, "Subclasses must implement #perform_match"
31
+ end
32
+
33
+ def response_body
34
+ if setup.response_body_method.present?
35
+ @actual.send(setup.response_body_method)
36
+ else
37
+ @actual
38
+ end
39
+ end
40
+
41
+ def parsed_json
42
+ @parsed_json ||= begin
43
+ body = response_body
44
+ case body
45
+ when Hash, Array
46
+ body
47
+ when String
48
+ JSON.parse(body)
49
+ else
50
+ raise ::APIMatchers::InvalidJSON, "Invalid JSON: '#{body}'"
51
+ end
52
+ end
53
+ rescue JSON::ParserError
54
+ raise ::APIMatchers::InvalidJSON, "Invalid JSON: '#{response_body}'"
55
+ end
56
+
57
+ def links_path
58
+ setup.links_path || '_links'
59
+ end
60
+
61
+ def hateoas_links
62
+ # Try configured path first
63
+ links = parsed_json[links_path] || parsed_json[links_path.to_sym]
64
+ return links if links
65
+
66
+ # Try common HATEOAS link locations
67
+ parsed_json['_links'] || parsed_json[:_links] ||
68
+ parsed_json['links'] || parsed_json[:links]
69
+ end
70
+
71
+ def truncate_json(json)
72
+ str = json.to_json
73
+ str.length > 200 ? "#{str[0..200]}..." : str
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module Hateoas
5
+ class HaveLink < Base
6
+ def initialize(rel)
7
+ @rel = rel.to_s
8
+ @expected_href = nil
9
+ @href_pattern = nil
10
+ end
11
+
12
+ def with_href(href_or_pattern)
13
+ case href_or_pattern
14
+ when Regexp
15
+ @href_pattern = href_or_pattern
16
+ else
17
+ @expected_href = href_or_pattern.to_s
18
+ end
19
+ self
20
+ end
21
+
22
+ def failure_message
23
+ links = hateoas_links
24
+
25
+ if links.nil?
26
+ "expected response to have link '#{@rel}', but no links were found. " \
27
+ "Looked at '#{links_path}'. Got: #{truncate_json(parsed_json)}"
28
+ elsif !link_exists?(links)
29
+ "expected response to have link '#{@rel}'. " \
30
+ "Available links: #{available_rels(links).inspect}"
31
+ elsif @expected_href
32
+ "expected link '#{@rel}' to have href '#{@expected_href}'. " \
33
+ "Got: '#{extract_href(links)}'"
34
+ elsif @href_pattern
35
+ "expected link '#{@rel}' href to match #{@href_pattern.inspect}. " \
36
+ "Got: '#{extract_href(links)}'"
37
+ else
38
+ "expected response to have link '#{@rel}'"
39
+ end
40
+ end
41
+
42
+ def failure_message_when_negated
43
+ if @expected_href
44
+ "expected link '#{@rel}' NOT to have href '#{@expected_href}', but it did"
45
+ elsif @href_pattern
46
+ "expected link '#{@rel}' NOT to match #{@href_pattern.inspect}, but it did"
47
+ else
48
+ "expected response NOT to have link '#{@rel}', but it was present"
49
+ end
50
+ end
51
+
52
+ def description
53
+ desc = "have link '#{@rel}'"
54
+ desc += " with href '#{@expected_href}'" if @expected_href
55
+ desc += " matching #{@href_pattern.inspect}" if @href_pattern
56
+ desc
57
+ end
58
+
59
+ protected
60
+
61
+ def perform_match
62
+ links = hateoas_links
63
+ return false unless links.is_a?(Hash)
64
+ return false unless link_exists?(links)
65
+
66
+ href = extract_href(links)
67
+
68
+ if @expected_href
69
+ href == @expected_href
70
+ elsif @href_pattern
71
+ href.to_s.match?(@href_pattern)
72
+ else
73
+ true
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def link_exists?(links)
80
+ links.key?(@rel) || links.key?(@rel.to_sym)
81
+ end
82
+
83
+ def extract_href(links)
84
+ link = links[@rel] || links[@rel.to_sym]
85
+ return nil unless link
86
+
87
+ case link
88
+ when Hash
89
+ link['href'] || link[:href]
90
+ when String
91
+ link
92
+ else
93
+ nil
94
+ end
95
+ end
96
+
97
+ def available_rels(links)
98
+ links.keys.map(&:to_s)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -1,10 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module APIMatchers
2
4
  module Headers
3
5
  class Base
4
- attr_reader :setup
5
-
6
- def initialize(setup)
7
- @setup = setup
6
+ def setup
7
+ ::APIMatchers::Core::Setup
8
8
  end
9
9
 
10
10
  def matches?(actual)
@@ -14,9 +14,9 @@ module APIMatchers
14
14
  end
15
15
 
16
16
  def content_type_response
17
- if @setup.header_method.present? and @setup.header_content_type_key.present?
18
- headers = @actual.send(@setup.header_method)
19
- headers[@setup.header_content_type_key] || headers if headers.present?
17
+ if setup.header_method.present? && setup.header_content_type_key.present?
18
+ headers = @actual.send(setup.header_method)
19
+ headers.present? ? (headers[setup.header_content_type_key] || headers) : nil
20
20
  else
21
21
  @actual
22
22
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module APIMatchers
2
4
  module Headers
3
5
  class BeJSON < Base
@@ -16,10 +18,6 @@ module APIMatchers
16
18
  def description
17
19
  "be in JSON"
18
20
  end
19
-
20
- # RSpec 2 compatibility:
21
- alias_method :failure_message_for_should, :failure_message
22
- alias_method :failure_message_for_should_not, :failure_message_when_negated
23
21
  end
24
22
  end
25
23
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module APIMatchers
2
4
  module Headers
3
5
  class BeXML < Base
@@ -16,10 +18,6 @@ module APIMatchers
16
18
  def description
17
19
  "be in XML"
18
20
  end
19
-
20
- # RSpec 2 compatibility:
21
- alias_method :failure_message_for_should, :failure_message
22
- alias_method :failure_message_for_should_not, :failure_message_when_negated
23
21
  end
24
22
  end
25
23
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module Headers
5
+ class HaveCacheControl
6
+ VALID_DIRECTIVES = %w[
7
+ public private no-cache no-store no-transform must-revalidate
8
+ proxy-revalidate max-age s-maxage immutable stale-while-revalidate
9
+ stale-if-error
10
+ ].freeze
11
+
12
+ attr_reader :actual
13
+
14
+ def initialize(*directives)
15
+ @expected_directives = normalize_directives(directives.flatten)
16
+ end
17
+
18
+ def setup
19
+ ::APIMatchers::Core::Setup
20
+ end
21
+
22
+ def matches?(actual)
23
+ @actual = actual
24
+ cache_control = fetch_cache_control
25
+ return false if cache_control.nil?
26
+
27
+ @actual_directives = parse_cache_control(cache_control)
28
+
29
+ @expected_directives.all? do |directive|
30
+ @actual_directives.include?(directive) ||
31
+ @actual_directives.any? { |d| d.start_with?("#{directive}=") }
32
+ end
33
+ end
34
+
35
+ def failure_message
36
+ cache_control = fetch_cache_control
37
+
38
+ if cache_control.nil?
39
+ "expected response to have Cache-Control header with #{@expected_directives.inspect}, " \
40
+ "but Cache-Control header was not present"
41
+ else
42
+ "expected Cache-Control to include #{@expected_directives.inspect}. " \
43
+ "Got: '#{cache_control}' (parsed: #{@actual_directives.inspect})"
44
+ end
45
+ end
46
+
47
+ def failure_message_when_negated
48
+ "expected Cache-Control NOT to include #{@expected_directives.inspect}, " \
49
+ "but it did: '#{fetch_cache_control}'"
50
+ end
51
+
52
+ def description
53
+ "have Cache-Control with #{@expected_directives.inspect}"
54
+ end
55
+
56
+ private
57
+
58
+ def normalize_directives(directives)
59
+ directives.map do |d|
60
+ d.to_s.tr('_', '-')
61
+ end
62
+ end
63
+
64
+ def fetch_cache_control
65
+ headers = available_headers
66
+ return nil unless headers
67
+
68
+ headers['Cache-Control'] ||
69
+ headers['cache-control'] ||
70
+ headers.find { |k, _| k.to_s.downcase == 'cache-control' }&.last
71
+ end
72
+
73
+ def parse_cache_control(value)
74
+ value.to_s.split(',').map(&:strip).map(&:downcase)
75
+ end
76
+
77
+ def available_headers
78
+ if setup.header_method.present?
79
+ @actual.send(setup.header_method)
80
+ elsif @actual.respond_to?(:headers)
81
+ @actual.headers
82
+ elsif @actual.is_a?(Hash)
83
+ @actual
84
+ else
85
+ {}
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module Headers
5
+ class HaveCorsHeaders
6
+ CORS_HEADERS = %w[
7
+ Access-Control-Allow-Origin
8
+ ].freeze
9
+
10
+ OPTIONAL_CORS_HEADERS = %w[
11
+ Access-Control-Allow-Methods
12
+ Access-Control-Allow-Headers
13
+ Access-Control-Allow-Credentials
14
+ Access-Control-Expose-Headers
15
+ Access-Control-Max-Age
16
+ ].freeze
17
+
18
+ attr_reader :actual
19
+
20
+ def initialize
21
+ @expected_origin = nil
22
+ end
23
+
24
+ def setup
25
+ ::APIMatchers::Core::Setup
26
+ end
27
+
28
+ def for_origin(origin)
29
+ @expected_origin = origin
30
+ self
31
+ end
32
+
33
+ def matches?(actual)
34
+ @actual = actual
35
+ headers = available_headers
36
+ return false unless headers
37
+
38
+ has_required_headers = CORS_HEADERS.all? { |h| header_present?(headers, h) }
39
+ return false unless has_required_headers
40
+
41
+ if @expected_origin
42
+ origin_value = fetch_header(headers, 'Access-Control-Allow-Origin')
43
+ return false unless origin_value == @expected_origin || origin_value == '*'
44
+ end
45
+
46
+ true
47
+ end
48
+
49
+ def failure_message
50
+ headers = available_headers
51
+ missing = CORS_HEADERS.reject { |h| header_present?(headers, h) }
52
+
53
+ if missing.any?
54
+ "expected response to have CORS headers. Missing: #{missing.inspect}. " \
55
+ "Available headers: #{headers&.keys&.inspect}"
56
+ elsif @expected_origin
57
+ origin_value = fetch_header(headers, 'Access-Control-Allow-Origin')
58
+ "expected Access-Control-Allow-Origin to be '#{@expected_origin}' or '*'. " \
59
+ "Got: '#{origin_value}'"
60
+ else
61
+ "expected response to have CORS headers"
62
+ end
63
+ end
64
+
65
+ def failure_message_when_negated
66
+ "expected response NOT to have CORS headers, but they were present"
67
+ end
68
+
69
+ def description
70
+ desc = "have CORS headers"
71
+ desc += " for origin '#{@expected_origin}'" if @expected_origin
72
+ desc
73
+ end
74
+
75
+ private
76
+
77
+ def available_headers
78
+ if setup.header_method.present?
79
+ @actual.send(setup.header_method)
80
+ elsif @actual.respond_to?(:headers)
81
+ @actual.headers
82
+ elsif @actual.is_a?(Hash)
83
+ @actual
84
+ else
85
+ {}
86
+ end
87
+ end
88
+
89
+ def header_present?(headers, name)
90
+ fetch_header(headers, name) != nil
91
+ end
92
+
93
+ def fetch_header(headers, name)
94
+ return nil unless headers
95
+
96
+ headers[name] ||
97
+ headers[name.downcase] ||
98
+ headers.find { |k, _| k.to_s.downcase == name.downcase }&.last
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module Headers
5
+ class HaveHeader
6
+ attr_reader :actual
7
+
8
+ def initialize(header_name)
9
+ @header_name = header_name.to_s
10
+ @expected_value = nil
11
+ @value_pattern = nil
12
+ end
13
+
14
+ def setup
15
+ ::APIMatchers::Core::Setup
16
+ end
17
+
18
+ def with_value(value)
19
+ @expected_value = value
20
+ self
21
+ end
22
+
23
+ def matching(pattern)
24
+ @value_pattern = pattern
25
+ self
26
+ end
27
+
28
+ def matches?(actual)
29
+ @actual = actual
30
+ header_value = fetch_header_value
31
+
32
+ return false if header_value.nil?
33
+
34
+ if @expected_value
35
+ header_value == @expected_value
36
+ elsif @value_pattern
37
+ header_value.to_s.match?(@value_pattern)
38
+ else
39
+ true
40
+ end
41
+ end
42
+
43
+ def failure_message
44
+ header_value = fetch_header_value
45
+
46
+ if header_value.nil?
47
+ "expected response to have header '#{@header_name}', but it was not present. " \
48
+ "Available headers: #{available_headers.keys.inspect}"
49
+ elsif @expected_value
50
+ "expected header '#{@header_name}' to have value '#{@expected_value}'. " \
51
+ "Got: '#{header_value}'"
52
+ elsif @value_pattern
53
+ "expected header '#{@header_name}' to match #{@value_pattern.inspect}. " \
54
+ "Got: '#{header_value}'"
55
+ else
56
+ "expected response to have header '#{@header_name}'"
57
+ end
58
+ end
59
+
60
+ def failure_message_when_negated
61
+ if @expected_value
62
+ "expected header '#{@header_name}' NOT to have value '#{@expected_value}', but it did"
63
+ elsif @value_pattern
64
+ "expected header '#{@header_name}' NOT to match #{@value_pattern.inspect}, but it did"
65
+ else
66
+ "expected response NOT to have header '#{@header_name}', but it was present"
67
+ end
68
+ end
69
+
70
+ def description
71
+ desc = "have header '#{@header_name}'"
72
+ desc += " with value '#{@expected_value}'" if @expected_value
73
+ desc += " matching #{@value_pattern.inspect}" if @value_pattern
74
+ desc
75
+ end
76
+
77
+ private
78
+
79
+ def fetch_header_value
80
+ headers = available_headers
81
+ return nil unless headers
82
+
83
+ # Try exact match first, then case-insensitive
84
+ headers[@header_name] ||
85
+ headers[@header_name.downcase] ||
86
+ headers.find { |k, _| k.to_s.downcase == @header_name.downcase }&.last
87
+ end
88
+
89
+ def available_headers
90
+ if setup.header_method.present?
91
+ @actual.send(setup.header_method)
92
+ elsif @actual.respond_to?(:headers)
93
+ @actual.headers
94
+ elsif @actual.is_a?(Hash)
95
+ @actual
96
+ else
97
+ {}
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module HTTPStatus
5
+ class Base
6
+ attr_reader :actual
7
+
8
+ def setup
9
+ ::APIMatchers::Core::Setup
10
+ end
11
+
12
+ def matches?(actual)
13
+ @actual = actual
14
+ expected_status_match?
15
+ end
16
+
17
+ def failure_message
18
+ "expected response to #{expectation_description}. Got: #{actual_status}"
19
+ end
20
+
21
+ def failure_message_when_negated
22
+ "expected response NOT to #{expectation_description}. Got: #{actual_status}"
23
+ end
24
+
25
+ def description
26
+ expectation_description
27
+ end
28
+
29
+ protected
30
+
31
+ def expected_status_match?
32
+ raise NotImplementedError, "Subclasses must implement #expected_status_match?"
33
+ end
34
+
35
+ def expectation_description
36
+ raise NotImplementedError, "Subclasses must implement #expectation_description"
37
+ end
38
+
39
+ def actual_status
40
+ if setup.http_status_method.present?
41
+ @actual.send(setup.http_status_method)
42
+ else
43
+ @actual
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module HTTPStatus
5
+ class BeClientError < Base
6
+ protected
7
+
8
+ def expected_status_match?
9
+ ::APIMatchers::Core::HTTPStatusCodes.client_error?(actual_status)
10
+ end
11
+
12
+ def expectation_description
13
+ "be client error (4xx)"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module HTTPStatus
5
+ class BeForbidden < Base
6
+ protected
7
+
8
+ def expected_status_match?
9
+ actual_status.to_i == 403
10
+ end
11
+
12
+ def expectation_description
13
+ "be forbidden (403)"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module HTTPStatus
5
+ class BeNoContent < Base
6
+ protected
7
+
8
+ def expected_status_match?
9
+ actual_status.to_i == 204
10
+ end
11
+
12
+ def expectation_description
13
+ "be no content (204)"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module HTTPStatus
5
+ class BeNotFound < Base
6
+ protected
7
+
8
+ def expected_status_match?
9
+ actual_status.to_i == 404
10
+ end
11
+
12
+ def expectation_description
13
+ "be not found (404)"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module HTTPStatus
5
+ class BeRedirect < Base
6
+ protected
7
+
8
+ def expected_status_match?
9
+ ::APIMatchers::Core::HTTPStatusCodes.redirect?(actual_status)
10
+ end
11
+
12
+ def expectation_description
13
+ "be redirect (3xx)"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module HTTPStatus
5
+ class BeServerError < Base
6
+ protected
7
+
8
+ def expected_status_match?
9
+ ::APIMatchers::Core::HTTPStatusCodes.server_error?(actual_status)
10
+ end
11
+
12
+ def expectation_description
13
+ "be server error (5xx)"
14
+ end
15
+ end
16
+ end
17
+ end