inferno_core 0.4.4 → 0.4.6

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0fc53d7cfc20506e263bf358bba6d477fb6dff4b0523530bd2aef3d191283c28
4
- data.tar.gz: aff080de4856187943390ee5478dfdd791a8962e21067be89aa7a5d40708e84c
3
+ metadata.gz: c72bacf34a6126ab54b88fa9c3ee7208482a01c12642cbb1e41c56ee8dc84efa
4
+ data.tar.gz: 8deef2f45523bb5d478bf356a41606d94927490a4f2787a05519d04cd77b37f0
5
5
  SHA512:
6
- metadata.gz: b1f8fb8df13079cc0f8f9007fb1d550ec96760fe9438bc4281115c26c1a6beb55e9e7dd826f87f6dea9d4b5c97c0546cb6090fbf2370febf5c2568c333f0fda4
7
- data.tar.gz: 9136dc23b84c4da21bbfd0c79cddab3d16e2aa25528a88b5636f47968aa8e801b657a1daac56f211ca0fd9c567484fcea8d1556ec8a0bc9fb206aa97ee5b8822
6
+ metadata.gz: 839e4bf45a435d27f0cbdd9eb0b8bca2c95516140b2948498919bad4ab1808dc84a6d5a080ca1cb6e9db5a4adf44893bc26c68676c0a7647a2a6886758a2cdfe
7
+ data.tar.gz: 2afdb71b425f861e5739b4ac57fcef47e611fbe2946a62c710dc3eb41e58287b640de39fb4c3ba275afc8cbc005aaaba3797142c8e4fb15b8d9b0f95d44649d3
@@ -2,6 +2,10 @@ require_relative '../exceptions'
2
2
 
3
3
  module Inferno
4
4
  module DSL
5
+ # This module contains the assertions used within tests to verify the
6
+ # behavior of the systems under test. Failing an assertion causes a test to
7
+ # immediately stop execution and receive a `fail` result. Additional
8
+ # assertions added to this module will be available in all tests.
5
9
  module Assertions
6
10
  # Make an assertion
7
11
  #
@@ -159,6 +163,10 @@ module Inferno
159
163
  assert uri =~ /\A#{URI::DEFAULT_PARSER.make_regexp(['http', 'https'])}\z/, error_message
160
164
  end
161
165
 
166
+ # Check the Content-Type header of a response
167
+ #
168
+ # @param type [String]
169
+ # @param request [Inferno::Entities::Request]
162
170
  def assert_response_content_type(type, request: self.request)
163
171
  header = request.response_header('Content-Type')
164
172
  assert header.present?, no_content_type_message
@@ -166,10 +174,12 @@ module Inferno
166
174
  assert header.value.start_with?(type), bad_content_type_message(type, header.value)
167
175
  end
168
176
 
177
+ # @private
169
178
  def no_content_type_message
170
179
  'Response did not contain a `Content-Type` header.'
171
180
  end
172
181
 
182
+ # @private
173
183
  def bad_content_type_message(expected, received)
174
184
  "Expected `Content-Type` to be `#{expected}`, but found `#{received}`"
175
185
  end
@@ -2,13 +2,85 @@ require_relative '../entities/input'
2
2
 
3
3
  module Inferno
4
4
  module DSL
5
- # This module contains the DSL for managing runnable configuration.
5
+ # This module contains the DSL for managing runnable configuration. Runnable
6
+ # configuration provides a way to modify test behavior at boot time.
7
+ #
8
+ # The main features enabled by configuration are:
9
+ # - Modifying the properties of a runnable's inputs. This could include
10
+ # locking a particular input, making a particular input optional/required,
11
+ # or changing an input's value.
12
+ # - Renaming an input/output/request to avoid name collisions when a test
13
+ # suite uses the same test multiple times.
14
+ # - Tests can define custom configuration options to enable different
15
+ # - testing behavior.
16
+ #
17
+ # @example
18
+ # test do
19
+ # id :json_request_test
20
+ #
21
+ # input :url
22
+ # output :response_body
23
+ # makes_request :json_request
24
+ #
25
+ # run do
26
+ # if config.options[:include_content_type]
27
+ # get url, headers: { 'Content-Type' => 'application/json' }
28
+ # else
29
+ # get url
30
+ # end
31
+ #
32
+ # assert_response_status(200)
33
+ # output response_body: request.response_body
34
+ # assert_valid_json
35
+ # end
36
+ # end
37
+ #
38
+ # group do
39
+ # test from :json_request_test do
40
+ # id :json_request_without_content_type
41
+ #
42
+ # config(
43
+ # inputs: {
44
+ # url: { name: :url_without_content_type }
45
+ # },
46
+ # outputs: {
47
+ # response_body: { name: :response_body_without_content_type }
48
+ # },
49
+ # requests: {
50
+ # json_request: { name: :json_request_without_content_type }
51
+ # }
52
+ # )
53
+ # end
54
+ #
55
+ # test from :json_request_test do
56
+ # id :json_request_with_content_type
57
+ #
58
+ # config(
59
+ # options: {
60
+ # include_content_type: true
61
+ # },
62
+ # inputs: {
63
+ # url: { name: :url_with_content_type }
64
+ # },
65
+ # outputs: {
66
+ # response_body: { name: :response_body_with_content_type }
67
+ # },
68
+ # requests: {
69
+ # json_request: { name: :json_request_with_content_type }
70
+ # }
71
+ # )
72
+ # end
73
+ # end
6
74
  module Configurable
7
75
  def self.extended(klass)
8
76
  klass.extend Forwardable
9
77
  klass.def_delegator 'self.class', :config
10
78
  end
11
79
 
80
+ # Define/update/get the configuration for a runnable. This configuration
81
+ # will be applied to the runnable and all of its children.
82
+ #
83
+ # @param new_configuration [Hash]
12
84
  def config(new_configuration = {})
13
85
  @config ||= Configuration.new
14
86
 
@@ -21,14 +93,18 @@ module Inferno
21
93
  @config
22
94
  end
23
95
 
24
- # @private
96
+ # This class stores a runnable's configuration. It should never be
97
+ # directly instantiated within a test suite. Instead, a runnable's
98
+ # configuration can be modified or retrieved using the `config` method.
25
99
  class Configuration
26
100
  attr_accessor :configuration
27
101
 
102
+ # @private
28
103
  def initialize(configuration = {})
29
104
  self.configuration = configuration
30
105
  end
31
106
 
107
+ # @private
32
108
  def apply(new_configuration)
33
109
  config_to_apply =
34
110
  if new_configuration.is_a? Configuration
@@ -44,16 +120,23 @@ module Inferno
44
120
  end
45
121
  end
46
122
 
123
+ # The configuration options defined for this runnable.
124
+ #
125
+ # @return [Hash]
47
126
  def options
48
127
  configuration[:options] ||= {}
49
128
  end
50
129
 
51
130
  ### Input Configuration ###
52
131
 
132
+ # The input configuration for this runnable.
133
+ #
134
+ # @return [Hash]
53
135
  def inputs
54
136
  configuration[:inputs] ||= {}
55
137
  end
56
138
 
139
+ # @private
57
140
  def add_input(identifier, new_config = {})
58
141
  existing_config = input(identifier)
59
142
 
@@ -67,81 +150,103 @@ module Inferno
67
150
  .merge(Entities::Input.new(**new_config))
68
151
  end
69
152
 
153
+ # @private
70
154
  def default_input_params(identifier)
71
155
  { name: identifier, type: 'text' }
72
156
  end
73
157
 
158
+ # @private
74
159
  def input_exists?(identifier)
75
160
  inputs.key? identifier
76
161
  end
77
162
 
163
+ # @private
78
164
  def input(identifier)
79
165
  inputs[identifier]
80
166
  end
81
167
 
168
+ # @private
82
169
  def input_name(identifier)
83
170
  inputs[identifier]&.name
84
171
  end
85
172
 
173
+ # @private
86
174
  def input_type(identifier)
87
175
  inputs[identifier]&.type
88
176
  end
89
177
 
90
178
  ### Output Configuration ###
91
179
 
180
+ # The output configuration for this runnable.
181
+ #
182
+ # @return [Hash]
92
183
  def outputs
93
184
  configuration[:outputs] ||= {}
94
185
  end
95
186
 
187
+ # @private
96
188
  def add_output(identifier, new_config = {})
97
189
  existing_config = output_config(identifier) || {}
98
190
  outputs[identifier] = default_output_config(identifier).merge(existing_config, new_config)
99
191
  end
100
192
 
193
+ # @private
101
194
  def default_output_config(identifier)
102
195
  { name: identifier, type: 'text' }
103
196
  end
104
197
 
198
+ # @private
105
199
  def output_config_exists?(identifier)
106
200
  outputs.key? identifier
107
201
  end
108
202
 
203
+ # @private
109
204
  def output_config(identifier)
110
205
  outputs[identifier]
111
206
  end
112
207
 
208
+ # @private
113
209
  def output_name(identifier)
114
210
  outputs.dig(identifier, :name) || identifier
115
211
  end
116
212
 
213
+ # @private
117
214
  def output_type(identifier)
118
215
  outputs.dig(identifier, :type)
119
216
  end
120
217
 
121
218
  ### Request Configuration ###
122
219
 
220
+ # The request configuration for this runnable.
221
+ #
222
+ # @return [Hash]
123
223
  def requests
124
224
  configuration[:requests] ||= {}
125
225
  end
126
226
 
227
+ # @private
127
228
  def add_request(identifier)
128
229
  return if request_config_exists?(identifier)
129
230
 
130
231
  requests[identifier] = default_request_config(identifier)
131
232
  end
132
233
 
234
+ # @private
133
235
  def default_request_config(identifier)
134
236
  { name: identifier }
135
237
  end
136
238
 
239
+ # @private
137
240
  def request_config_exists?(identifier)
138
241
  requests.key? identifier
139
242
  end
140
243
 
244
+ # @private
141
245
  def request_config(identifier)
142
246
  requests[identifier]
143
247
  end
144
248
 
249
+ # @private
145
250
  def request_name(identifier)
146
251
  requests.dig(identifier, :name) || identifier
147
252
  end
@@ -34,7 +34,7 @@ module Inferno
34
34
  # end
35
35
  # end
36
36
  # end
37
- # @see Inferno::FHIRClientBuilder Documentation for the client
37
+ # @see Inferno::DSL::FHIRClientBuilder Documentation for the client
38
38
  # configuration DSL
39
39
  module FHIRClient
40
40
  # @private
@@ -43,15 +43,13 @@ module Inferno
43
43
  klass.extend Forwardable
44
44
  klass.include RequestStorage
45
45
  klass.include TCPExceptionHandler
46
-
47
- klass.def_delegators 'self.class', :profile_url, :validator_url
48
46
  end
49
47
 
50
48
  # Return a previously defined FHIR client
51
49
  #
52
50
  # @param client [Symbol] the name of the client
53
51
  # @return [FHIR::Client]
54
- # @see Inferno::FHIRClientBuilder
52
+ # @see Inferno::DSL::FHIRClientBuilder
55
53
  def fhir_client(client = :default)
56
54
  fhir_clients[client] ||=
57
55
  FHIRClientBuilder.new.build(self, self.class.fhir_client_definitions[client])
@@ -101,6 +99,21 @@ module Inferno
101
99
  end
102
100
  end
103
101
 
102
+ # Perform a FHIR create interaction.
103
+ #
104
+ # @param resource [FHIR::Model]
105
+ # @param client [Symbol]
106
+ # @param name [Symbol] Name for this request to allow it to be used by
107
+ # other tests
108
+ # @return [Inferno::Entities::Request]
109
+ def fhir_create(resource, client: :default, name: nil)
110
+ store_request_and_refresh_token(fhir_client(client), name) do
111
+ tcp_exception_handler do
112
+ fhir_client(client).create(resource)
113
+ end
114
+ end
115
+ end
116
+
104
117
  # Perform a FHIR read interaction.
105
118
  #
106
119
  # @param resource_type [String, Symbol, Class]
@@ -158,9 +171,25 @@ module Inferno
158
171
  end
159
172
  end
160
173
 
174
+ # Perform a FHIR batch/transaction interaction.
175
+ #
176
+ # @param bundle [FHIR::Bundle] the FHIR batch/transaction Bundle
177
+ # @param client [Symbol]
178
+ # @param name [Symbol] Name for this request to allow it to be used by
179
+ # other tests
180
+ # @return [Inferno::Entities::Request]
181
+ def fhir_transaction(bundle = nil, client: :default, name: nil)
182
+ store_request('outgoing', name) do
183
+ tcp_exception_handler do
184
+ fhir_client(client).transaction_bundle = bundle if bundle.present?
185
+ fhir_client(client).end_transaction
186
+ end
187
+ end
188
+ end
189
+
161
190
  # @todo Make this a FHIR class method? Something like
162
191
  # FHIR.class_for(resource_type)
163
- # @api private
192
+ # @private
164
193
  def fhir_class_from_resource_type(resource_type)
165
194
  FHIR.const_get(resource_type.to_s.camelize)
166
195
  end
@@ -168,7 +197,7 @@ module Inferno
168
197
  # This method wraps a request to automatically refresh its access token if
169
198
  # expired. It's combined with `store_request` so that all of the fhir
170
199
  # request methods don't have to be wrapped twice.
171
- # @api private
200
+ # @private
172
201
  def store_request_and_refresh_token(client, name, &block)
173
202
  store_request('outgoing', name) do
174
203
  perform_refresh(client) if client.need_to_refresh? && client.able_to_refresh?
@@ -176,7 +205,7 @@ module Inferno
176
205
  end
177
206
  end
178
207
 
179
- # @api private
208
+ # @private
180
209
  def perform_refresh(client)
181
210
  credentials = client.oauth_credentials
182
211
 
@@ -203,7 +232,7 @@ module Inferno
203
232
  end
204
233
 
205
234
  module ClassMethods
206
- # @api private
235
+ # @private
207
236
  def fhir_client_definitions
208
237
  @fhir_client_definitions ||= {}
209
238
  end
@@ -4,6 +4,22 @@ require_relative '../ext/fhir_client'
4
4
  module Inferno
5
5
  module DSL
6
6
  # DSL for configuring FHIR clients
7
+ #
8
+ # @example
9
+ # input :url
10
+ # input :fhir_credentials, type: :oauth_credentials
11
+ # input :access_token
12
+ #
13
+ # fhir_client do
14
+ # url :url
15
+ # headers 'My-Custom_header' => 'CUSTOM_HEADER_VALUE'
16
+ # oauth_credentials :fhir_credentials
17
+ # end
18
+ #
19
+ # fhir_client :with_bearer_token do
20
+ # url :url
21
+ # bearer_token :access_token
22
+ # end
7
23
  class FHIRClientBuilder
8
24
  attr_accessor :runnable
9
25
 
@@ -28,7 +28,7 @@ module Inferno
28
28
  # end
29
29
  # end
30
30
  # end
31
- # @see Inferno::FHIRClientBuilder Documentation for the client
31
+ # @see Inferno::DSL::HTTPClientBuilder Documentation for the client
32
32
  # configuration DSL
33
33
  module HTTPClient
34
34
  # @private
@@ -2,6 +2,9 @@ require_relative '../entities/attributes'
2
2
 
3
3
  module Inferno
4
4
  module DSL
5
+ # OAuthCredentials provide a user with a single input which allows a fhir
6
+ # client to use a bearer token and automatically refresh the token when it
7
+ # expires.
5
8
  class OAuthCredentials
6
9
  ATTRIBUTES = [
7
10
  :access_token,
@@ -18,6 +21,16 @@ module Inferno
18
21
 
19
22
  attr_accessor :client
20
23
 
24
+ # @!attribute [rw] access_token
25
+ # @!attribute [rw] refresh_token
26
+ # @!attribute [rw] token_url
27
+ # @!attribute [rw] client_id
28
+ # @!attribute [rw] client_secret
29
+ # @!attribute [rw] token_retrieval_time
30
+ # @!attribute [rw] expires_in
31
+ # @!attribute [rw] name
32
+
33
+ # @private
21
34
  def initialize(raw_attributes_hash)
22
35
  attributes_hash = raw_attributes_hash.symbolize_keys
23
36
 
@@ -34,7 +47,7 @@ module Inferno
34
47
  self.token_retrieval_time = DateTime.now if access_token.present? && token_retrieval_time.blank?
35
48
  end
36
49
 
37
- # @api private
50
+ # @private
38
51
  def to_hash
39
52
  self.class::ATTRIBUTES.each_with_object({}) do |attribute, hash|
40
53
  value = send(attribute)
@@ -46,12 +59,12 @@ module Inferno
46
59
  end
47
60
  end
48
61
 
49
- # @api private
62
+ # @private
50
63
  def to_s
51
64
  JSON.generate(to_hash)
52
65
  end
53
66
 
54
- # @api private
67
+ # @private
55
68
  def add_to_client(client)
56
69
  client.oauth_credentials = self
57
70
  self.client = client
@@ -61,7 +74,7 @@ module Inferno
61
74
  client.set_bearer_token(access_token)
62
75
  end
63
76
 
64
- # @api private
77
+ # @private
65
78
  def need_to_refresh?
66
79
  return false if access_token.blank? || refresh_token.blank?
67
80
 
@@ -70,17 +83,17 @@ module Inferno
70
83
  token_retrieval_time.to_i + expires_in - DateTime.now.to_i < 60
71
84
  end
72
85
 
73
- # @api private
86
+ # @private
74
87
  def able_to_refresh?
75
88
  refresh_token.present? && token_url.present?
76
89
  end
77
90
 
78
- # @api private
91
+ # @private
79
92
  def confidential_client?
80
93
  client_id.present? && client_secret.present?
81
94
  end
82
95
 
83
- # @api private
96
+ # @private
84
97
  def oauth2_refresh_params
85
98
  {
86
99
  'grant_type' => 'refresh_token',
@@ -88,7 +101,7 @@ module Inferno
88
101
  }
89
102
  end
90
103
 
91
- # @api private
104
+ # @private
92
105
  def oauth2_refresh_headers
93
106
  base_headers = { 'Content-Type' => 'application/x-www-form-urlencoded' }
94
107
 
@@ -101,6 +114,7 @@ module Inferno
101
114
  )
102
115
  end
103
116
 
117
+ # @private
104
118
  def update_from_response_body(request)
105
119
  token_response_body = JSON.parse(request.response_body)
106
120
 
@@ -16,7 +16,7 @@ module Inferno
16
16
 
17
17
  # Returns the most recent FHIR/HTTP request
18
18
  #
19
- # @return [Inferno::Entities::Request]
19
+ # @return [Inferno::Entities::Request, nil]
20
20
  def request
21
21
  requests.last
22
22
  end
@@ -36,7 +36,7 @@ module Inferno
36
36
  request&.resource
37
37
  end
38
38
 
39
- # TODO: do a check in the test runner
39
+ # @private
40
40
  def named_request(name)
41
41
  requests.find { |request| request.name == self.class.config.request_name(name.to_sym) }
42
42
  end
@@ -82,10 +82,12 @@ module Inferno
82
82
  raise Exceptions::WaitException, message
83
83
  end
84
84
 
85
+ # @private
85
86
  def identifier(identifier = nil)
86
87
  @identifier ||= identifier
87
88
  end
88
89
 
90
+ # @private
89
91
  def wait_timeout(timeout = nil)
90
92
  @wait_timeout ||= timeout
91
93
  end
@@ -46,6 +46,7 @@ module Inferno
46
46
  # copied, and will need to be repopulated from scratch on the new class.
47
47
  # Any child Runnable classes will themselves need to be subclassed so that
48
48
  # their parent can be updated.
49
+ # @private
49
50
  VARIABLES_NOT_TO_COPY = [
50
51
  :@id, # New runnable will have a different id
51
52
  :@parent, # New runnable unlikely to have the same parent
@@ -307,12 +308,6 @@ module Inferno
307
308
  @all_children ||= []
308
309
  end
309
310
 
310
- def validator_url(url = nil)
311
- return @validator_url ||= parent&.validator_url if url.nil?
312
-
313
- @validator_url = url
314
- end
315
-
316
311
  # @private
317
312
  def suite
318
313
  return self if ancestors.include? Inferno::Entities::TestSuite
@@ -397,6 +392,28 @@ module Inferno
397
392
  (parent.user_runnable? && !parent.run_as_group?)
398
393
  end
399
394
 
395
+ # Set/get suite options required for this runnable to be executed.
396
+ #
397
+ # @param suite_option_requirements [Hash]
398
+ # @example
399
+ # suite_option :ig_version,
400
+ # list_options: [
401
+ # {
402
+ # label: 'IG v1',
403
+ # value: 'ig_v1'
404
+ # },
405
+ # {
406
+ # label: 'IG v2',
407
+ # value: 'ig_v2'
408
+ # }
409
+ # ]
410
+ #
411
+ # group from: :ig_v1_group,
412
+ # required_suite_options: { ig_version: 'ig_v1' }
413
+ #
414
+ # group from: :ig_v2_group do
415
+ # required_suite_options ig_version: 'ig_v2'
416
+ # end
400
417
  def required_suite_options(suite_option_requirements)
401
418
  @suite_option_requirements =
402
419
  suite_option_requirements.map do |key, value|
@@ -404,6 +421,7 @@ module Inferno
404
421
  end
405
422
  end
406
423
 
424
+ # @private
407
425
  def children(selected_suite_options = [])
408
426
  return all_children if selected_suite_options.blank?
409
427
 
@@ -417,6 +435,16 @@ module Inferno
417
435
  end
418
436
  end
419
437
  end
438
+
439
+ def inspect
440
+ non_dynamic_ancestor = ancestors.find { |ancestor| !ancestor.to_s.start_with? '#' }
441
+ "#<#{non_dynamic_ancestor}".tap do |inspect_string|
442
+ inspect_string.concat(" @id=#{id.inspect},")
443
+ inspect_string.concat(" @short_id=#{short_id.inspect},") if respond_to? :short_id
444
+ inspect_string.concat(" @title=#{title.inspect}")
445
+ inspect_string.concat('>')
446
+ end
447
+ end
420
448
  end
421
449
  end
422
450
  end
@@ -2,6 +2,11 @@ require_relative '../entities/attributes'
2
2
 
3
3
  module Inferno
4
4
  module DSL
5
+ # This class is used to represent TestSuite-level options which are selected
6
+ # by the user, and can affect which tests/groups are displayed and run as
7
+ # well as the behavior of those tests.
8
+ #
9
+ # @see Inferno::Entities::TestSuite.suite_option
5
10
  class SuiteOption
6
11
  ATTRIBUTES = [
7
12
  :id,
@@ -13,6 +18,13 @@ module Inferno
13
18
 
14
19
  include Entities::Attributes
15
20
 
21
+ # @!attribute [rw] id
22
+ # @!attribute [rw] title
23
+ # @!attribute [rw] description
24
+ # @!attribute [rw] list_options
25
+ # @!attribute [rw] value
26
+
27
+ # @private
16
28
  def initialize(raw_params)
17
29
  params = raw_params.deep_symbolize_keys
18
30
  bad_params = params.keys - ATTRIBUTES
@@ -26,10 +38,12 @@ module Inferno
26
38
  self.id = id.to_sym if id.is_a? String
27
39
  end
28
40
 
41
+ # @private
29
42
  def ==(other)
30
43
  id == other.id && value == other.value
31
44
  end
32
45
 
46
+ # @private
33
47
  def to_hash
34
48
  self.class::ATTRIBUTES.each_with_object({}) do |attribute, hash|
35
49
  hash[attribute] = send(attribute)
@@ -1,5 +1,6 @@
1
1
  module Inferno
2
2
  module DSL
3
+ # @private
3
4
  module TCPExceptionHandler
4
5
  def tcp_exception_handler(&block)
5
6
  block.call
@@ -1,5 +1,6 @@
1
1
  module Inferno
2
2
  module Entities
3
+ # @private
3
4
  module Attributes
4
5
  def self.included(klass)
5
6
  klass.attr_accessor(*klass::ATTRIBUTES)
@@ -42,6 +42,7 @@ module Inferno
42
42
 
43
43
  include Attributes
44
44
 
45
+ # @private
45
46
  def initialize(params)
46
47
  super(params, ATTRIBUTES - [:headers, :name])
47
48
 
@@ -86,7 +87,8 @@ module Inferno
86
87
 
87
88
  # Return a hash of the request parameters
88
89
  #
89
- # @return [Hash]
90
+ # @return [Hash] A Hash with `:verb`, `:url`, `:headers`, and `:body`
91
+ # fields
90
92
  def request
91
93
  {
92
94
  verb:,
@@ -98,7 +100,7 @@ module Inferno
98
100
 
99
101
  # Return a hash of the response parameters
100
102
  #
101
- # @return [Hash]
103
+ # @return [Hash] A Hash with `:status`, `:headers`, and `:body` fields
102
104
  def response
103
105
  {
104
106
  status:,