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,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module HTTPStatus
5
+ class BeSuccessful < Base
6
+ protected
7
+
8
+ def expected_status_match?
9
+ ::APIMatchers::Core::HTTPStatusCodes.successful?(actual_status)
10
+ end
11
+
12
+ def expectation_description
13
+ "be successful (2xx)"
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 BeUnauthorized < Base
6
+ protected
7
+
8
+ def expected_status_match?
9
+ actual_status.to_i == 401
10
+ end
11
+
12
+ def expectation_description
13
+ "be unauthorized (401)"
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 BeUnprocessable < Base
6
+ protected
7
+
8
+ def expected_status_match?
9
+ actual_status.to_i == 422
10
+ end
11
+
12
+ def expectation_description
13
+ "be unprocessable (422)"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module HTTPStatus
5
+ class HaveHttpStatus < Base
6
+ def initialize(expected)
7
+ @expected = expected
8
+ end
9
+
10
+ protected
11
+
12
+ def expected_status_match?
13
+ actual_status.to_i == expected_code
14
+ end
15
+
16
+ def expectation_description
17
+ if @expected.is_a?(Symbol)
18
+ "have HTTP status #{expected_code} (#{@expected})"
19
+ else
20
+ "have HTTP status #{expected_code}"
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def expected_code
27
+ case @expected
28
+ when Symbol
29
+ ::APIMatchers::Core::HTTPStatusCodes.symbol_to_code(@expected) ||
30
+ raise(ArgumentError, "Unknown status code symbol: #{@expected}")
31
+ when Integer
32
+ @expected
33
+ else
34
+ raise ArgumentError, "Expected status must be a Symbol or Integer, got: #{@expected.class}"
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module APIMatchers
6
+ module JsonApi
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 json_api_data
58
+ parsed_json['data'] || parsed_json[:data]
59
+ end
60
+
61
+ def json_api_errors
62
+ parsed_json['errors'] || parsed_json[:errors]
63
+ end
64
+
65
+ def json_api_meta
66
+ parsed_json['meta'] || parsed_json[:meta]
67
+ end
68
+
69
+ def json_api_included
70
+ parsed_json['included'] || parsed_json[:included]
71
+ end
72
+
73
+ def json_api_links
74
+ parsed_json['links'] || parsed_json[:links]
75
+ end
76
+
77
+ def truncate_json(json)
78
+ str = json.to_json
79
+ str.length > 200 ? "#{str[0..200]}..." : str
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module JsonApi
5
+ class BeJsonApiCompliant < Base
6
+ REQUIRED_TOP_LEVEL_MEMBERS = %w[data errors meta].freeze
7
+ VALID_TOP_LEVEL_MEMBERS = %w[data errors meta links included jsonapi].freeze
8
+ REQUIRED_RESOURCE_MEMBERS = %w[id type].freeze
9
+
10
+ def failure_message
11
+ "expected response to be JSON:API compliant. Violations: #{@violations.join('; ')}. " \
12
+ "Got: #{truncate_json(parsed_json)}"
13
+ end
14
+
15
+ def failure_message_when_negated
16
+ "expected response NOT to be JSON:API compliant, but it was"
17
+ end
18
+
19
+ def description
20
+ "be JSON:API compliant"
21
+ end
22
+
23
+ protected
24
+
25
+ def perform_match
26
+ @violations = []
27
+ validate_top_level_structure
28
+ return @violations.empty? unless parsed_json.is_a?(Hash)
29
+
30
+ validate_data_member if has_data?
31
+ validate_errors_member if has_errors?
32
+
33
+ @violations.empty?
34
+ end
35
+
36
+ private
37
+
38
+ def validate_top_level_structure
39
+ unless parsed_json.is_a?(Hash)
40
+ @violations << "top-level must be an object"
41
+ return
42
+ end
43
+
44
+ # Must have at least one of: data, errors, or meta
45
+ keys = parsed_json.keys.map(&:to_s)
46
+ unless (keys & REQUIRED_TOP_LEVEL_MEMBERS).any?
47
+ @violations << "must contain at least one of: data, errors, or meta"
48
+ end
49
+
50
+ # data and errors must not coexist
51
+ if has_data? && has_errors?
52
+ @violations << "data and errors must not coexist"
53
+ end
54
+ end
55
+
56
+ def validate_data_member
57
+ data = json_api_data
58
+
59
+ case data
60
+ when Hash
61
+ validate_resource_object(data)
62
+ when Array
63
+ data.each_with_index do |resource, index|
64
+ validate_resource_object(resource, "data[#{index}]")
65
+ end
66
+ when nil
67
+ # null is valid for data
68
+ else
69
+ @violations << "data must be null, an object, or an array"
70
+ end
71
+ end
72
+
73
+ def validate_resource_object(resource, path = "data")
74
+ unless resource.is_a?(Hash)
75
+ @violations << "#{path} must be an object"
76
+ return
77
+ end
78
+
79
+ keys = resource.keys.map(&:to_s)
80
+
81
+ # Must have id and type (unless it's a new resource being created)
82
+ unless keys.include?('type')
83
+ @violations << "#{path} must contain 'type'"
84
+ end
85
+
86
+ # id must be a string
87
+ if resource.key?('id') || resource.key?(:id)
88
+ id = resource['id'] || resource[:id]
89
+ unless id.is_a?(String) || id.is_a?(Integer)
90
+ @violations << "#{path}.id must be a string or integer"
91
+ end
92
+ end
93
+
94
+ # type must be a string
95
+ type = resource['type'] || resource[:type]
96
+ if type && !type.is_a?(String)
97
+ @violations << "#{path}.type must be a string"
98
+ end
99
+
100
+ # Validate attributes
101
+ attributes = resource['attributes'] || resource[:attributes]
102
+ if attributes && !attributes.is_a?(Hash)
103
+ @violations << "#{path}.attributes must be an object"
104
+ end
105
+
106
+ # Validate relationships
107
+ relationships = resource['relationships'] || resource[:relationships]
108
+ if relationships
109
+ validate_relationships(relationships, path)
110
+ end
111
+ end
112
+
113
+ def validate_relationships(relationships, path)
114
+ unless relationships.is_a?(Hash)
115
+ @violations << "#{path}.relationships must be an object"
116
+ return
117
+ end
118
+
119
+ relationships.each do |name, rel|
120
+ rel_path = "#{path}.relationships.#{name}"
121
+ unless rel.is_a?(Hash)
122
+ @violations << "#{rel_path} must be an object"
123
+ next
124
+ end
125
+
126
+ # Must have at least one of: links, data, or meta
127
+ rel_keys = rel.keys.map(&:to_s)
128
+ unless (rel_keys & %w[links data meta]).any?
129
+ @violations << "#{rel_path} must contain links, data, or meta"
130
+ end
131
+ end
132
+ end
133
+
134
+ def validate_errors_member
135
+ errors = json_api_errors
136
+
137
+ unless errors.is_a?(Array)
138
+ @violations << "errors must be an array"
139
+ return
140
+ end
141
+
142
+ errors.each_with_index do |error, index|
143
+ unless error.is_a?(Hash)
144
+ @violations << "errors[#{index}] must be an object"
145
+ end
146
+ end
147
+ end
148
+
149
+ def has_data?
150
+ parsed_json.key?('data') || parsed_json.key?(:data)
151
+ end
152
+
153
+ def has_errors?
154
+ parsed_json.key?('errors') || parsed_json.key?(:errors)
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module JsonApi
5
+ class HaveJsonApiAttributes < Base
6
+ def initialize(*attributes)
7
+ @expected_attributes = attributes.flatten.map(&:to_s)
8
+ end
9
+
10
+ def failure_message
11
+ data = json_api_data
12
+ attrs = extract_attributes(data)
13
+
14
+ if attrs.nil?
15
+ "expected JSON:API data to have attributes #{@expected_attributes.inspect}, " \
16
+ "but no attributes were found. Got: #{truncate_json(parsed_json)}"
17
+ else
18
+ missing = @expected_attributes - attrs.keys.map(&:to_s)
19
+ "expected JSON:API data to have attributes #{@expected_attributes.inspect}. " \
20
+ "Missing: #{missing.inspect}. Available: #{attrs.keys.map(&:to_s).inspect}"
21
+ end
22
+ end
23
+
24
+ def failure_message_when_negated
25
+ "expected JSON:API data NOT to have attributes #{@expected_attributes.inspect}, " \
26
+ "but all were present"
27
+ end
28
+
29
+ def description
30
+ "have JSON:API attributes #{@expected_attributes.inspect}"
31
+ end
32
+
33
+ protected
34
+
35
+ def perform_match
36
+ data = json_api_data
37
+ return false unless data
38
+
39
+ attrs = extract_attributes(data)
40
+ return false unless attrs.is_a?(Hash)
41
+
42
+ available_attrs = attrs.keys.map(&:to_s)
43
+ (@expected_attributes - available_attrs).empty?
44
+ end
45
+
46
+ private
47
+
48
+ def extract_attributes(data)
49
+ case data
50
+ when Hash
51
+ data['attributes'] || data[:attributes]
52
+ when Array
53
+ # For collections, check the first item
54
+ first = data.first
55
+ first['attributes'] || first[:attributes] if first.is_a?(Hash)
56
+ else
57
+ nil
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module JsonApi
5
+ class HaveJsonApiData < Base
6
+ def initialize
7
+ @expected_type = nil
8
+ @expected_id = nil
9
+ end
10
+
11
+ def of_type(type)
12
+ @expected_type = type.to_s
13
+ self
14
+ end
15
+
16
+ def with_id(id)
17
+ @expected_id = id.to_s
18
+ self
19
+ end
20
+
21
+ def failure_message
22
+ data = json_api_data
23
+
24
+ if data.nil? && !parsed_json.key?('data') && !parsed_json.key?(:data)
25
+ "expected response to have JSON:API data. Got: #{truncate_json(parsed_json)}"
26
+ elsif @expected_type && !type_matches?(data)
27
+ "expected JSON:API data to have type '#{@expected_type}'. " \
28
+ "Got type: '#{extract_type(data)}'"
29
+ elsif @expected_id && !id_matches?(data)
30
+ "expected JSON:API data to have id '#{@expected_id}'. " \
31
+ "Got id: '#{extract_id(data)}'"
32
+ else
33
+ "expected response to have JSON:API data"
34
+ end
35
+ end
36
+
37
+ def failure_message_when_negated
38
+ if @expected_type || @expected_id
39
+ "expected JSON:API data NOT to have " \
40
+ "#{[@expected_type && "type '#{@expected_type}'", @expected_id && "id '#{@expected_id}'"].compact.join(' and ')}, " \
41
+ "but it did"
42
+ else
43
+ "expected response NOT to have JSON:API data, but it did"
44
+ end
45
+ end
46
+
47
+ def description
48
+ desc = "have JSON:API data"
49
+ desc += " of type '#{@expected_type}'" if @expected_type
50
+ desc += " with id '#{@expected_id}'" if @expected_id
51
+ desc
52
+ end
53
+
54
+ protected
55
+
56
+ def perform_match
57
+ data = json_api_data
58
+
59
+ # data: null is valid JSON:API
60
+ return true if data.nil? && (parsed_json.key?('data') || parsed_json.key?(:data))
61
+
62
+ return false unless data
63
+
64
+ if @expected_type && !type_matches?(data)
65
+ return false
66
+ end
67
+
68
+ if @expected_id && !id_matches?(data)
69
+ return false
70
+ end
71
+
72
+ true
73
+ end
74
+
75
+ private
76
+
77
+ def type_matches?(data)
78
+ case data
79
+ when Hash
80
+ extract_type(data) == @expected_type
81
+ when Array
82
+ data.all? { |d| extract_type(d) == @expected_type }
83
+ else
84
+ false
85
+ end
86
+ end
87
+
88
+ def id_matches?(data)
89
+ case data
90
+ when Hash
91
+ extract_id(data) == @expected_id
92
+ when Array
93
+ data.any? { |d| extract_id(d) == @expected_id }
94
+ else
95
+ false
96
+ end
97
+ end
98
+
99
+ def extract_type(resource)
100
+ return nil unless resource.is_a?(Hash)
101
+ (resource['type'] || resource[:type])&.to_s
102
+ end
103
+
104
+ def extract_id(resource)
105
+ return nil unless resource.is_a?(Hash)
106
+ (resource['id'] || resource[:id])&.to_s
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module JsonApi
5
+ class HaveJsonApiRelationships < Base
6
+ def initialize(*relationships)
7
+ @expected_relationships = relationships.flatten.map(&:to_s)
8
+ end
9
+
10
+ def failure_message
11
+ data = json_api_data
12
+ rels = extract_relationships(data)
13
+
14
+ if rels.nil?
15
+ "expected JSON:API data to have relationships #{@expected_relationships.inspect}, " \
16
+ "but no relationships were found. Got: #{truncate_json(parsed_json)}"
17
+ else
18
+ missing = @expected_relationships - rels.keys.map(&:to_s)
19
+ "expected JSON:API data to have relationships #{@expected_relationships.inspect}. " \
20
+ "Missing: #{missing.inspect}. Available: #{rels.keys.map(&:to_s).inspect}"
21
+ end
22
+ end
23
+
24
+ def failure_message_when_negated
25
+ "expected JSON:API data NOT to have relationships #{@expected_relationships.inspect}, " \
26
+ "but all were present"
27
+ end
28
+
29
+ def description
30
+ "have JSON:API relationships #{@expected_relationships.inspect}"
31
+ end
32
+
33
+ protected
34
+
35
+ def perform_match
36
+ data = json_api_data
37
+ return false unless data
38
+
39
+ rels = extract_relationships(data)
40
+ return false unless rels.is_a?(Hash)
41
+
42
+ available_rels = rels.keys.map(&:to_s)
43
+ (@expected_relationships - available_rels).empty?
44
+ end
45
+
46
+ private
47
+
48
+ def extract_relationships(data)
49
+ case data
50
+ when Hash
51
+ data['relationships'] || data[:relationships]
52
+ when Array
53
+ # For collections, check the first item
54
+ first = data.first
55
+ first['relationships'] || first[:relationships] if first.is_a?(Hash)
56
+ else
57
+ nil
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module APIMatchers
6
+ module JsonStructure
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 json_at_path(path)
58
+ return parsed_json if path.nil? || path.empty?
59
+
60
+ finder = ::APIMatchers::Core::JsonPathFinder.new(parsed_json)
61
+ finder.find(path)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module APIMatchers
4
+ module JsonStructure
5
+ class HaveJsonKeys < Base
6
+ def initialize(*keys)
7
+ @expected_keys = keys.flatten.map(&:to_s)
8
+ @path = nil
9
+ end
10
+
11
+ def at_path(path)
12
+ @path = path
13
+ self
14
+ end
15
+
16
+ def failure_message
17
+ "expected JSON to have keys: #{@expected_keys.inspect}. " \
18
+ "Missing: #{missing_keys.inspect}. " \
19
+ "Got keys: #{actual_keys.inspect}"
20
+ end
21
+
22
+ def failure_message_when_negated
23
+ "expected JSON NOT to have keys: #{@expected_keys.inspect}, but all were present"
24
+ end
25
+
26
+ def description
27
+ desc = "have JSON keys #{@expected_keys.inspect}"
28
+ desc += " at path '#{@path}'" if @path
29
+ desc
30
+ end
31
+
32
+ protected
33
+
34
+ def perform_match
35
+ @json_data = json_at_path(@path)
36
+ return false unless @json_data.is_a?(Hash)
37
+
38
+ missing_keys.empty?
39
+ rescue ::APIMatchers::Core::Exceptions::PathNotFound
40
+ @json_data = nil
41
+ false
42
+ end
43
+
44
+ private
45
+
46
+ def actual_keys
47
+ @json_data.is_a?(Hash) ? @json_data.keys.map(&:to_s) : []
48
+ end
49
+
50
+ def missing_keys
51
+ @expected_keys - actual_keys
52
+ end
53
+ end
54
+ end
55
+ end