airborne-rails 0.99.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c1a75ecbbfe8a614852a6d113d03254eed84d26d0e0715e70d051335090610bd
4
+ data.tar.gz: cbf2103b4072492d434988e02dba5ed3bda47cc515a77b16867f2c1037be0975
5
+ SHA512:
6
+ metadata.gz: b88e1bdcc2daa43b7386695480a35da4a31573c367b4ca4b910f1f74b8346a4bd6b2232ce66e1352a0caf5b7067dd71a6ce01392b4040bd931cde088fc12ce4a
7
+ data.tar.gz: e31ba52e1d369cf0f19087dc7b1efa87b3b963627ef8dabdcb491a804e19318ed031d796f3fbb661a14471ef922411da304bcb8ee9d00f474caea2e653cdf36c
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airborne
4
+ # rubocop:todo Metrics/ModuleLength
5
+ module Expectations
6
+ def expect_json_types(*args)
7
+ call_with_path(args) do |param, body|
8
+ expect_json_types_impl(param, body)
9
+ end
10
+ end
11
+
12
+ def expect_json(*args)
13
+ call_with_path(args) do |param, body|
14
+ expect_json_impl(param, body)
15
+ end
16
+ end
17
+
18
+ def expect_json_keys(*args)
19
+ call_with_path(args) do |param, body|
20
+ expect(body.keys).to include(*param)
21
+ end
22
+ end
23
+
24
+ def expect_json_sizes(*args)
25
+ args.push(convert_expectations_for_json_sizes(args.pop))
26
+
27
+ expect_json_types(*args)
28
+ end
29
+
30
+ def expect_status(code)
31
+ code = Rack::Utils::SYMBOL_TO_STATUS_CODE[code] if code.is_a?(Symbol)
32
+
33
+ expect(response.code.to_s).to eq(code.to_s)
34
+ end
35
+
36
+ def expect_header(key, content)
37
+ expect_header_impl(key, content)
38
+ end
39
+
40
+ def expect_header_contains(key, content)
41
+ expect_header_impl(key, content, true)
42
+ end
43
+
44
+ private
45
+
46
+ def get_by_path(...)
47
+ PathMatcher.call(...)
48
+ end
49
+
50
+ def expect_header_impl(key, content, contains = nil)
51
+ header = headers[key]
52
+ raise RSpec::Expectations::ExpectationNotMetError, "Header #{key} not present in the HTTP response" unless header
53
+
54
+ if contains
55
+ expect(header.downcase).to include(content.downcase)
56
+ else
57
+ expect(header.downcase).to eq(content.downcase)
58
+ end
59
+ end
60
+
61
+ # rubocop:todo Metrics/AbcSize
62
+ # rubocop:todo Metrics/CyclomaticComplexity
63
+ # rubocop:todo Metrics/MethodLength
64
+ # rubocop:todo Metrics/PerceivedComplexity
65
+ def expect_json_impl(expected, actual)
66
+ actual = actual.to_s if expected.is_a?(Regexp)
67
+
68
+ return expect(actual).to match(expected) if property?(expected)
69
+
70
+ keys = []
71
+
72
+ keys << expected.keys if match_expected?
73
+ keys << actual.keys if match_actual?
74
+ keys = expected.keys & actual.keys if match_none?
75
+
76
+ keys.flatten.uniq.each do |prop|
77
+ expected_value = extract_expected_value(expected, prop)
78
+ actual_value = extract_actual(actual, prop)
79
+
80
+ next expect_json_impl(expected_value, actual_value) if hash?(expected_value) && hash?(actual_value)
81
+ next expected_value.call(actual_value) if expected_value.is_a?(Proc)
82
+ next expect(actual_value.to_s).to match(expected_value) if expected_value.is_a?(Regexp)
83
+
84
+ expect(actual_value).to eq(expected_value)
85
+ end
86
+ end
87
+
88
+ def expect_json_types_impl(expected, actual)
89
+ @mapper ||= get_mapper
90
+
91
+ actual = convert_to_date(actual) if (expected == :date) || (expected == :date_or_null)
92
+
93
+ return expect_type(expected, actual) if expected.is_a?(Symbol)
94
+ return expected.call(actual) if expected.is_a?(Proc)
95
+
96
+ keys = []
97
+
98
+ keys << expected.keys if match_expected?
99
+ keys << actual.keys if match_actual?
100
+ keys = expected.keys & actual.keys if match_none?
101
+
102
+ keys.flatten.uniq.each do |prop|
103
+ type = extract_expected_type(expected, prop)
104
+ value = extract_actual(actual, prop)
105
+ value = convert_to_date(value) if (type == :date) || (type == :date_or_null)
106
+
107
+ next expect_json_types_impl(type, value) if hash?(type)
108
+ next type.call(value) if type.is_a?(Proc)
109
+
110
+ type_string = type.to_s
111
+
112
+ if type_string.include?("array_of") && !(type_string.include?("or_null") && value.nil?)
113
+ check_array_types(value, prop, type)
114
+ else
115
+ expect_type(type, value, prop)
116
+ end
117
+ end
118
+ end
119
+ # rubocop:enable Metrics/ModuleLength
120
+ # rubocop:enable Metrics/AbcSize
121
+ # rubocop:enable Metrics/CyclomaticComplexity
122
+ # rubocop:enable Metrics/MethodLength
123
+ # rubocop:enable Metrics/PerceivedComplexity
124
+
125
+ def call_with_path(args)
126
+ if args.length == 2
127
+ get_by_path(args[0], json_body) do |json_chunk|
128
+ yield(args[1], json_chunk)
129
+ end
130
+ else
131
+ yield(args[0], json_body)
132
+ end
133
+ end
134
+
135
+ def extract_expected_value(expected, prop)
136
+ raise unless expected.key?(prop)
137
+
138
+ expected[prop]
139
+ rescue StandardError
140
+ raise ExpectationError, "Expectation is expected to contain property: #{prop}"
141
+ end
142
+
143
+ def extract_expected_type(expected, prop)
144
+ type = expected[prop]
145
+ type.nil? ? raise : type
146
+ rescue StandardError
147
+ raise ExpectationError, "Expectation is expected to contain property: #{prop}"
148
+ end
149
+
150
+ def extract_actual(actual, prop)
151
+ actual[prop]
152
+ rescue StandardError
153
+ raise ExpectationError, "Expected #{actual.class} #{actual}\nto be an object with property #{prop}"
154
+ end
155
+
156
+ def expect_type(expected_type, value, prop_name = nil)
157
+ raise ExpectationError, "Expected type #{expected_type}\nis an invalid type" if @mapper[expected_type].nil?
158
+
159
+ insert = prop_name.nil? ? "" : "#{prop_name} to be of type"
160
+ message = "Expected #{insert} #{expected_type}\n got #{value.class} instead"
161
+
162
+ expect(@mapper[expected_type].any? { |type| value.is_a?(type) }).to eq(true), message
163
+ end
164
+
165
+ def convert_to_date(value)
166
+ DateTime.parse(value)
167
+ rescue StandardError
168
+ nil
169
+ end
170
+
171
+ def check_array_types(value, prop_name, expected_type)
172
+ expect_array(value, prop_name, expected_type)
173
+ value.each do |val|
174
+ expect_type(expected_type, val, prop_name)
175
+ end
176
+ end
177
+
178
+ def hash?(hash)
179
+ hash.is_a?(Hash)
180
+ end
181
+
182
+ def expect_array(value, prop_name, expected_type)
183
+ expect(value.class).to eq(Array),
184
+ "Expected #{prop_name}\n to be of type #{expected_type}\n got #{value.class} instead"
185
+ end
186
+
187
+ def convert_expectations_for_json_sizes(old_expectations)
188
+ return convert_expectation_for_json_sizes(old_expectations) unless old_expectations.is_a?(Hash)
189
+
190
+ old_expectations.each_with_object({}) do |(prop_name, expected_size), memo|
191
+ new_value = if expected_size.is_a?(Hash)
192
+ convert_expectations_for_json_sizes(expected_size)
193
+ else
194
+ convert_expectation_for_json_sizes(expected_size)
195
+ end
196
+ memo[prop_name] = new_value
197
+ end
198
+ end
199
+
200
+ def convert_expectation_for_json_sizes(expected_size)
201
+ ->(data) { expect(data.size).to eq(expected_size) }
202
+ end
203
+
204
+ def property?(expectation)
205
+ [String, Regexp, Float, Integer, TrueClass, FalseClass, NilClass, Array].any? { |type| expectation.is_a?(type) }
206
+ end
207
+
208
+ def get_mapper # rubocop:todo Metrics/MethodLength
209
+ base_mapper = {
210
+ integer: [Integer],
211
+ array_of_integers: [Integer],
212
+ int: [Integer],
213
+ array_of_ints: [Integer],
214
+ float: [Float, Integer],
215
+ array_of_floats: [Float, Integer],
216
+ string: [String],
217
+ array_of_strings: [String],
218
+ boolean: [TrueClass, FalseClass],
219
+ array_of_booleans: [TrueClass, FalseClass],
220
+ bool: [TrueClass, FalseClass],
221
+ array_of_bools: [TrueClass, FalseClass],
222
+ object: [Hash],
223
+ array_of_objects: [Hash],
224
+ array: [Array],
225
+ array_of_arrays: [Array],
226
+ date: [DateTime],
227
+ null: [NilClass]
228
+ }
229
+
230
+ mapper = base_mapper.clone
231
+ base_mapper.each do |key, value|
232
+ mapper[:"#{key}_or_null"] = value + [NilClass]
233
+ end
234
+ mapper
235
+ end
236
+
237
+ def match_none?
238
+ !match_actual? && !match_expected?
239
+ end
240
+
241
+ def match_actual?
242
+ RSpec.configuration.airborne_match_actual?
243
+ end
244
+
245
+ def match_expected?
246
+ RSpec.configuration.airborne_match_expected?
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airborne
4
+ module Helpers
5
+ def response
6
+ @response
7
+ end
8
+
9
+ def headers
10
+ response
11
+ .headers
12
+ .transform_keys { |k| k.to_s.underscore.to_sym }
13
+ .with_indifferent_access
14
+ end
15
+
16
+ def body
17
+ response.body
18
+ end
19
+
20
+ def json_body
21
+ JSON.parse(response.body, symbolize_names: true)
22
+ rescue StandardError
23
+ raise InvalidJsonError, "API request returned invalid json"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airborne
4
+ class PathMatcher
5
+ ALL = "?"
6
+ ANY = "*"
7
+
8
+ def self.call(...)
9
+ new.call(...)
10
+ end
11
+
12
+ # rubocop:todo Metrics/AbcSize,Metrics/MethodLength
13
+ def call(path, json, &)
14
+ raise PathError, "Invalid Path, contains '..'" if path.include?("..")
15
+
16
+ type = false
17
+ parts = path.split(".")
18
+ parts.each_with_index do |part, index|
19
+ if [ALL, ANY].include?(part)
20
+ ensure_array(path, json)
21
+ type = part
22
+
23
+ if index < parts.length.pred
24
+ walk_with_path(type, index, path, parts, json, &)
25
+ return # rubocop:todo Lint/NonLocalExitFromIterator
26
+ end
27
+
28
+ next
29
+ end
30
+
31
+ json = process_json(part, json)
32
+ end
33
+
34
+ if type == ANY
35
+ expect_all(json, &)
36
+ elsif type == ALL
37
+ expect_one(path, json, &)
38
+ else
39
+ yield json
40
+ end
41
+ end
42
+ # rubocop:enable Metrics/AbcSize,Metrics/MethodLength
43
+
44
+ private
45
+
46
+ # rubocop:todo Metrics/MethodLength
47
+ def walk_with_path(type, index, path, parts, json, &)
48
+ last_error = nil
49
+ item_count = json.length
50
+ error_count = 0
51
+ json.each do |element|
52
+ begin
53
+ sub_path = parts[(index.next)...(parts.length)].join(".")
54
+ self.class.call(sub_path, element, &)
55
+ rescue RSpec::Expectations::ExpectationNotMetError => e
56
+ last_error = e
57
+ error_count += 1
58
+ end
59
+
60
+ ensure_match_all(last_error) if type == "*"
61
+ ensure_match_one(path, item_count, error_count) if type == "?"
62
+ end
63
+ end
64
+ # rubocop:enable Metrics/MethodLength
65
+
66
+ def process_json(part, json)
67
+ if index?(part) && json.is_a?(Array)
68
+ json[part.to_i]
69
+ else
70
+ json[part.to_sym]
71
+ end
72
+ end
73
+
74
+ def index?(part)
75
+ part =~ /^\d+$/
76
+ end
77
+
78
+ def expect_one(path, json)
79
+ item_count = json.length
80
+ error_count = 0
81
+ json.each do |part|
82
+ yield part
83
+ rescue RSpec::Expectations::ExpectationNotMetError
84
+ error_count += 1
85
+ ensure_match_one(path, item_count, error_count)
86
+ end
87
+ end
88
+
89
+ def expect_all(json, &)
90
+ last_error = nil
91
+ begin
92
+ json.each(&)
93
+ rescue RSpec::Expectations::ExpectationNotMetError => e
94
+ last_error = e
95
+ end
96
+ ensure_match_all(last_error)
97
+ end
98
+
99
+ def ensure_match_one(path, item_count, error_count)
100
+ return unless item_count == error_count
101
+
102
+ raise RSpec::Expectations::ExpectationNotMetError,
103
+ "Expected one object in path #{path} to match provided JSON values"
104
+ end
105
+
106
+ def ensure_match_all(error)
107
+ raise error unless error.nil?
108
+ end
109
+
110
+ def ensure_array(path, json)
111
+ return if json.is_a?(Array)
112
+
113
+ raise RSpec::Expectations::ExpectationNotMetError,
114
+ "Expected #{path} to be array got #{json.class} from JSON response"
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AirborneRails
4
+ VERSION = "0.99.0"
5
+ end
data/lib/airborne.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "airborne/path_matcher"
4
+ require "airborne/helpers"
5
+ require "airborne/expectations"
6
+
7
+ module Airborne
8
+ class InvalidJsonError < StandardError; end
9
+ class PathError < StandardError; end
10
+ class ExpectationError < StandardError; end
11
+ end
12
+
13
+ RSpec.configure do |config|
14
+ config.add_setting :airborne_match_expected
15
+ config.add_setting :airborne_match_actual
16
+
17
+ config.before do |e|
18
+ config.airborne_match_expected =
19
+ e.metadata[:airborne_match_expected].nil? ? true : e.metadata[:airborne_match_expected]
20
+ config.airborne_match_actual =
21
+ e.metadata[:airborne_match_actual].nil? ? false : e.metadata[:airborne_match_actual]
22
+ end
23
+
24
+ config.include Airborne::Helpers
25
+ config.include Airborne::Expectations
26
+ end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: airborne-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.99.0
5
+ platform: ruby
6
+ authors:
7
+ - Placewise Devs
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-03-26 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rspec
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec-rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ email:
41
+ - mpc.dev@placewise.com
42
+ executables: []
43
+ extensions: []
44
+ extra_rdoc_files: []
45
+ files:
46
+ - lib/airborne.rb
47
+ - lib/airborne/expectations.rb
48
+ - lib/airborne/helpers.rb
49
+ - lib/airborne/path_matcher.rb
50
+ - lib/airborne/version.rb
51
+ homepage: https://github.com/Placewise/airborne-rails
52
+ licenses:
53
+ - MIT
54
+ metadata: {}
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 3.0.0
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 3.6.2
70
+ specification_version: 4
71
+ summary: RSpec helpers and expectations for Rails-based JSON APIs - extracted from
72
+ airborne
73
+ test_files: []