inferno_core 0.0.8 → 0.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/lib/inferno/apps/web/controllers/test_runs/create.rb +2 -1
  3. data/lib/inferno/apps/web/serializers/result.rb +1 -0
  4. data/lib/inferno/apps/web/serializers/test.rb +4 -0
  5. data/lib/inferno/apps/web/serializers/test_group.rb +4 -0
  6. data/lib/inferno/apps/web/serializers/test_suite.rb +3 -0
  7. data/lib/inferno/db/migrations/001_create_initial_structure.rb +54 -53
  8. data/lib/inferno/db/migrations/002_add_wait_support.rb +2 -2
  9. data/lib/inferno/db/migrations/003_update_session_data.rb +7 -7
  10. data/lib/inferno/db/migrations/004_add_request_results_table.rb +2 -2
  11. data/lib/inferno/db/migrations/005_add_updated_at_index_to_results.rb +1 -1
  12. data/lib/inferno/db/migrations/006_remove_unused_tables.rb +38 -0
  13. data/lib/inferno/db/schema.rb +17 -43
  14. data/lib/inferno/dsl/configurable.rb +12 -5
  15. data/lib/inferno/dsl/fhir_client.rb +58 -6
  16. data/lib/inferno/dsl/fhir_client_builder.rb +16 -0
  17. data/lib/inferno/dsl/http_client.rb +62 -0
  18. data/lib/inferno/dsl/oauth_credentials.rb +119 -0
  19. data/lib/inferno/dsl/runnable.rb +87 -9
  20. data/lib/inferno/entities/has_runnable.rb +26 -0
  21. data/lib/inferno/entities/request.rb +7 -1
  22. data/lib/inferno/entities/result.rb +8 -4
  23. data/lib/inferno/entities/test_group.rb +4 -6
  24. data/lib/inferno/entities/test_run.rb +1 -18
  25. data/lib/inferno/entities/test_suite.rb +3 -4
  26. data/lib/inferno/entities.rb +1 -0
  27. data/lib/inferno/exceptions.rb +19 -0
  28. data/lib/inferno/ext/fhir_client.rb +13 -0
  29. data/lib/inferno/public/bundle.js +14 -14
  30. data/lib/inferno/repositories/session_data.rb +40 -6
  31. data/lib/inferno/result_summarizer.rb +40 -0
  32. data/lib/inferno/spec_support.rb +1 -1
  33. data/lib/inferno/test_runner.rb +13 -9
  34. data/lib/inferno/version.rb +1 -1
  35. data/spec/factories/request.rb +1 -1
  36. data/spec/factories/result.rb +2 -2
  37. data/spec/fixtures/basic_test_group.rb +1 -0
  38. metadata +7 -2
@@ -103,6 +103,68 @@ module Inferno
103
103
  end
104
104
  end
105
105
 
106
+ # Perform an HTTP DELETE request
107
+ #
108
+ # @param url [String] if this request is using a defined client, this will
109
+ # be appended to the client's url. Must be an absolute url for requests
110
+ # made without a defined client
111
+ # @param client [Symbol]
112
+ # @param name [Symbol] Name for this request to allow it to be used by
113
+ # other tests
114
+ # @return [Inferno::Entities::Request]
115
+ def delete(url = '', client: :default, name: :nil, **options)
116
+ store_request('outgoing', name) do
117
+ client = http_client(client)
118
+
119
+ if client
120
+ client.delete(url, nil, options[:headers])
121
+ elsif url.match?(%r{\Ahttps?://})
122
+ Faraday.delete(url, nil, options[:headers])
123
+ else
124
+ raise StandardError, 'Must use an absolute url or define an HTTP client with a base url'
125
+ end
126
+ end
127
+ end
128
+
129
+ # Perform an HTTP GET request and stream the response
130
+ #
131
+ # @param block [Proc] A Proc object whose input will be the string chunks
132
+ # received while streaming response to the get request.
133
+ # @param url [String] If this request is using a defined client, this will
134
+ # be appended to the client's url. Must be an absolute url for requests
135
+ # made without a defined client
136
+ # @param limit [Integer] The number of streamed-in chunks to be stored in
137
+ # the response body. This optional input defaults to 100.
138
+ # @param client [Symbol]
139
+ # @param name [Symbol] Name for this request to allow it to be used by
140
+ # other tests
141
+ # @option options [Hash] Input headers here - headers are optional and
142
+ # must be entered as the last piece of input to this method
143
+ # @return [Inferno::Entities::Request]
144
+ def stream(block, url = '', limit = 100, client: :default, name: nil, **options)
145
+ streamed = []
146
+
147
+ collector = proc do |chunk, bytes|
148
+ streamed << chunk if limit.positive?
149
+ limit -= 1
150
+ block.call(chunk, bytes)
151
+ end
152
+
153
+ store_request('outgoing', name) do
154
+ client = http_client(client)
155
+
156
+ if client
157
+ response = client.get(url, nil, options[:headers]) { |req| req.options.on_data = collector }
158
+ elsif url.match?(%r{\Ahttps?://})
159
+ response = Faraday.get(url, nil, options[:headers]) { |req| req.options.on_data = collector }
160
+ else
161
+ raise StandardError, 'Must use an absolute url or define an HTTP client with a base url'
162
+ end
163
+ response.env.body = streamed.join
164
+ response
165
+ end
166
+ end
167
+
106
168
  module ClassMethods
107
169
  # @private
108
170
  def http_client_definitions
@@ -0,0 +1,119 @@
1
+ require_relative '../entities/attributes'
2
+
3
+ module Inferno
4
+ module DSL
5
+ class OAuthCredentials
6
+ ATTRIBUTES = [
7
+ :access_token,
8
+ :refresh_token,
9
+ :token_url,
10
+ :client_id,
11
+ :client_secret,
12
+ :token_retrieval_time,
13
+ :expires_in,
14
+ :name
15
+ ].freeze
16
+
17
+ include Entities::Attributes
18
+
19
+ attr_accessor :client
20
+
21
+ def initialize(raw_attributes_hash)
22
+ attributes_hash = raw_attributes_hash.symbolize_keys
23
+
24
+ invalid_keys = attributes_hash.keys - ATTRIBUTES
25
+
26
+ raise Exceptions::UnknownAttributeException.new(invalid_keys, self.class) if invalid_keys.present?
27
+
28
+ attributes_hash.each do |name, value|
29
+ value = DateTime.parse(value) if name == :token_retrieval_time && value.is_a?(String)
30
+
31
+ instance_variable_set(:"@#{name}", value)
32
+ end
33
+
34
+ self.token_retrieval_time = DateTime.now if token_retrieval_time.blank?
35
+ end
36
+
37
+ # @api private
38
+ def to_hash
39
+ self.class::ATTRIBUTES.each_with_object({}) do |attribute, hash|
40
+ value = send(attribute)
41
+ next if value.nil?
42
+
43
+ value = token_retrieval_time.iso8601 if attribute == :token_retrieval_time
44
+
45
+ hash[attribute] = value
46
+ end
47
+ end
48
+
49
+ # @api private
50
+ def to_s
51
+ JSON.generate(to_hash)
52
+ end
53
+
54
+ # @api private
55
+ def add_to_client(client)
56
+ client.oauth_credentials = self
57
+ self.client = client
58
+
59
+ return unless access_token.present?
60
+
61
+ client.set_bearer_token(access_token)
62
+ end
63
+
64
+ # @api private
65
+ def need_to_refresh?
66
+ return false if access_token.blank? || refresh_token.blank?
67
+
68
+ return true if expires_in.blank?
69
+
70
+ token_retrieval_time.to_i + expires_in - DateTime.now.to_i < 60
71
+ end
72
+
73
+ # @api private
74
+ def able_to_refresh?
75
+ refresh_token.present? && token_url.present?
76
+ end
77
+
78
+ # @api private
79
+ def confidential_client?
80
+ client_id.present? && client_secret.present?
81
+ end
82
+
83
+ # @api private
84
+ def oauth2_refresh_params
85
+ {
86
+ 'grant_type' => 'refresh_token',
87
+ 'refresh_token' => refresh_token
88
+ }
89
+ end
90
+
91
+ # @api private
92
+ def oauth2_refresh_headers
93
+ base_headers = { 'Content-Type' => 'application/x-www-form-urlencoded' }
94
+
95
+ return base_headers unless confidential_client?
96
+
97
+ credentials = "#{client_id}:#{client_secret}"
98
+
99
+ base_headers.merge(
100
+ 'Authorization' => "Basic #{Base64.strict_encode64(credentials)}"
101
+ )
102
+ end
103
+
104
+ def update_from_response_body(request)
105
+ token_response_body = JSON.parse(request.response_body)
106
+
107
+ expires_in = token_response_body['expires_in'].is_a?(Numeric) ? token_response_body['expires_in'] : nil
108
+
109
+ self.access_token = token_response_body['access_token']
110
+ self.refresh_token = token_response_body['refresh_token']
111
+ self.expires_in = expires_in
112
+ self.token_retrieval_time = DateTime.now
113
+
114
+ add_to_client(client)
115
+ self
116
+ end
117
+ end
118
+ end
119
+ end
@@ -85,7 +85,6 @@ module Inferno
85
85
 
86
86
  klass.parent = self
87
87
 
88
- child_metadata[:collection] << klass
89
88
  children << klass
90
89
 
91
90
  configure_child_class(klass, hash_args)
@@ -164,6 +163,8 @@ module Inferno
164
163
 
165
164
  klass.config(config)
166
165
 
166
+ klass.children.select!(&:required?) if hash_args.delete(:exclude_optional)
167
+
167
168
  hash_args.each do |key, value|
168
169
  if value.is_a? Array
169
170
  klass.send(key, *value)
@@ -212,6 +213,16 @@ module Inferno
212
213
  @title = new_title
213
214
  end
214
215
 
216
+ # Set/Get a runnable's short title
217
+ #
218
+ # @param new_short_title [String]
219
+ # @return [String] the short title
220
+ def short_title(new_short_title = nil)
221
+ return @short_title if new_short_title.nil?
222
+
223
+ @short_title = new_short_title
224
+ end
225
+
215
226
  # Set/Get a runnable's description
216
227
  #
217
228
  # @param new_description [String]
@@ -222,6 +233,26 @@ module Inferno
222
233
  @description = format_markdown(new_description)
223
234
  end
224
235
 
236
+ # Set/Get a runnable's short one-sentence description
237
+ #
238
+ # @param new_short_description [String]
239
+ # @return [String] the one-sentence description
240
+ def short_description(new_short_description = nil)
241
+ return @short_description if new_short_description.nil?
242
+
243
+ @short_description = format_markdown(new_short_description)
244
+ end
245
+
246
+ # Set/Get a runnable's input instructions
247
+ #
248
+ # @param new_input_instructions [String]
249
+ # @return [String] the input instructions
250
+ def input_instructions(new_input_instructions = nil)
251
+ return @input_instructions if new_input_instructions.nil?
252
+
253
+ @input_instructions = format_markdown(new_input_instructions)
254
+ end
255
+
225
256
  # Define inputs
226
257
  #
227
258
  # @param identifier [Symbol] identifier for the input
@@ -229,12 +260,14 @@ module Inferno
229
260
  # @param input_definition [Hash] options for input such as type, description, or title
230
261
  # @option input_definition [String] :title Human readable title for input
231
262
  # @option input_definition [String] :description Description for the input
232
- # @option input_definition [String] :type text | textarea
263
+ # @option input_definition [String] :type text | textarea | radio
233
264
  # @option input_definition [String] :default The default value for the input
234
265
  # @option input_definition [Boolean] :optional Set to true to not require input for test execution
266
+ # @option input_definition [Hash] :options Possible input option formats based on input type
267
+ # @option options [Array] :list_options Array of options for input formats that require a list of possible values
235
268
  # @return [void]
236
269
  # @example
237
- # input :patientid, title: 'Patient ID', description: 'The ID of the patient being searched for',
270
+ # input :patient_id, title: 'Patient ID', description: 'The ID of the patient being searched for',
238
271
  # default: 'default_patient_id'
239
272
  # @example
240
273
  # input :textarea, title: 'Textarea Input Example', type: 'textarea', optional: true
@@ -250,16 +283,60 @@ module Inferno
250
283
  end
251
284
  end
252
285
 
286
+ # Mark as optional. Tests are required by default.
287
+ #
288
+ # @param optional [Boolean]
289
+ # @return [void]
290
+ #
291
+ def optional(optional = true) # rubocop:disable Style/OptionalBooleanParameter
292
+ @optional = optional
293
+ end
294
+
295
+ # Mark as required
296
+ #
297
+ # Tests are required by default. This method is provided to make an
298
+ # existing optional test required.
299
+ #
300
+ # @param required[Boolean]
301
+ # @return [void]
302
+ def required(required = true) # rubocop:disable Style/OptionalBooleanParameter
303
+ @optional = !required
304
+ end
305
+
306
+ # The test or group is optional if true
307
+ #
308
+ # @return [Boolean]
309
+ def optional?
310
+ !!@optional
311
+ end
312
+
313
+ # The test or group is required if true
314
+ #
315
+ # @return [Boolean]
316
+ def required?
317
+ !optional?
318
+ end
319
+
253
320
  # Define outputs
254
321
  #
255
- # @param output_list [Symbol]
322
+ # @param identifier [Symbol] identifier for the output
323
+ # @param other_identifiers [Symbol] array of symbols if specifying multiple outputs
324
+ # @param output_definition [Hash] options for output
325
+ # @option output_definition [String] :type text | textarea | oauth_credentials
256
326
  # @return [void]
257
327
  # @example
258
- # output :patient_id, :bearer_token
259
- def output(*output_list)
260
- output_list.each do |output_identifier|
261
- outputs << output_identifier
262
- config.add_output(output_identifier)
328
+ # output :patient_id, :condition_id, :observation_id
329
+ # @example
330
+ # output :oauth_credentials, type: 'oauth_credentials'
331
+ def output(identifier, *other_identifiers, **output_definition)
332
+ if other_identifiers.present?
333
+ [identifier, *other_identifiers].compact.each do |output_identifier|
334
+ outputs << output_identifier
335
+ config.add_output(output_identifier)
336
+ end
337
+ else
338
+ outputs << identifier
339
+ config.add_output(identifier, output_definition)
263
340
  end
264
341
  end
265
342
 
@@ -395,6 +472,7 @@ module Inferno
395
472
  required_inputs.map(&:to_s) - submitted_inputs.map { |input| input[:name] }
396
473
  end
397
474
 
475
+ # @private
398
476
  def user_runnable?
399
477
  @user_runnable ||= parent.nil? ||
400
478
  !parent.respond_to?(:run_as_group?) ||
@@ -0,0 +1,26 @@
1
+ module Inferno
2
+ module Entities
3
+ module HasRunnable
4
+ # Returns the Test, TestGroup, or TestSuite associated with this entity
5
+ #
6
+ # @return [Inferno::Entities::Test, Inferno::Entities::TestGroup, Inferno::Entities::TestSuite]
7
+ def runnable
8
+ return @runnable if @runnable
9
+
10
+ @runnable = (test || test_group || test_suite || load_runnable)
11
+ end
12
+
13
+ private
14
+
15
+ def load_runnable
16
+ if test_id.present?
17
+ @test = Inferno::Repositories::Tests.new.find(test_id)
18
+ elsif test_group_id.present?
19
+ @test_group = Inferno::Repositories::TestGroups.new.find(test_group_id)
20
+ elsif test_suite_id.present?
21
+ @test_suite = Inferno::Repositories::TestSuites.new.find(test_suite_id)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -186,6 +186,12 @@ module Inferno
186
186
  .map { |header_name, value| Header.new(name: header_name.downcase, value: value, type: 'request') }
187
187
  response_headers = response[:headers]
188
188
  .map { |header_name, value| Header.new(name: header_name.downcase, value: value, type: 'response') }
189
+ request_body =
190
+ if request.dig(:headers, 'Content-Type')&.include?('application/x-www-form-urlencoded')
191
+ URI.encode_www_form(request[:payload])
192
+ else
193
+ request[:payload]
194
+ end
189
195
 
190
196
  new(
191
197
  verb: request[:method],
@@ -193,7 +199,7 @@ module Inferno
193
199
  direction: direction,
194
200
  name: name,
195
201
  status: response[:code].to_i,
196
- request_body: request[:payload],
202
+ request_body: request_body,
197
203
  response_body: response[:body],
198
204
  test_session_id: test_session_id,
199
205
  headers: request_headers + response_headers
@@ -47,9 +47,10 @@ module Inferno
47
47
  :test_session_id, :result, :result_message, :messages, :requests,
48
48
  :input_json, :output_json
49
49
  ].freeze
50
- RESULT_OPTIONS = ['cancel', 'wait', 'running', 'error', 'fail', 'skip', 'omit', 'pass'].freeze
50
+ RESULT_OPTIONS = ['cancel', 'wait', 'running', 'error', 'fail', 'skip', 'pass', 'omit'].freeze
51
51
 
52
52
  include Inferno::Entities::Attributes
53
+ include Inferno::Entities::HasRunnable
53
54
 
54
55
  def initialize(params)
55
56
  super(params, ATTRIBUTES - [:messages, :requests])
@@ -58,9 +59,12 @@ module Inferno
58
59
  @requests = (params[:requests] || []).map { |request| Request.new(request) }
59
60
  end
60
61
 
61
- # @return [Inferno::Entities::Test, Inferno::Entities::TestGroup, Inferno::Entities::TestSuite]
62
- def runnable
63
- test || test_group || test_suite
62
+ def optional?
63
+ runnable.optional?
64
+ end
65
+
66
+ def required?
67
+ !optional?
64
68
  end
65
69
 
66
70
  # @return [Boolean]
@@ -32,11 +32,11 @@ module Inferno
32
32
  end
33
33
 
34
34
  def groups
35
- @groups ||= []
35
+ children.select { |child| child < Inferno::Entities::TestGroup }
36
36
  end
37
37
 
38
38
  def tests
39
- @tests ||= []
39
+ children.select { |child| child < Inferno::Entities::Test }
40
40
  end
41
41
 
42
42
  # Methods to configure Inferno::DSL::Runnable
@@ -54,16 +54,14 @@ module Inferno
54
54
  def group_metadata
55
55
  {
56
56
  class: TestGroup,
57
- repo: repository,
58
- collection: groups
57
+ repo: repository
59
58
  }
60
59
  end
61
60
 
62
61
  def test_metadata
63
62
  {
64
63
  class: Test,
65
- repo: Inferno::Repositories::Tests.new,
66
- collection: tests
64
+ repo: Inferno::Repositories::Tests.new
67
65
  }
68
66
  end
69
67
 
@@ -52,6 +52,7 @@ module Inferno
52
52
  ].freeze
53
53
 
54
54
  include Inferno::Entities::Attributes
55
+ include Inferno::Entities::HasRunnable
55
56
 
56
57
  attr_accessor :test_session
57
58
 
@@ -63,12 +64,6 @@ module Inferno
63
64
  @test_session = params[:test_session]
64
65
  end
65
66
 
66
- def runnable
67
- return @runnable if @runnable
68
-
69
- @runnable = (test || test_group || test_suite || load_runnable)
70
- end
71
-
72
67
  def to_hash
73
68
  super.merge(test_session: test_session).compact
74
69
  end
@@ -76,18 +71,6 @@ module Inferno
76
71
  def test_count
77
72
  @test_count ||= runnable.test_count
78
73
  end
79
-
80
- private
81
-
82
- def load_runnable
83
- if test_id.present?
84
- @test = Inferno::Repositories::Tests.new.find(test_id)
85
- elsif test_group_id.present?
86
- @test_group = Inferno::Repositories::TestGroups.new.find(test_group_id)
87
- elsif test_suite_id.present?
88
- @test_suite = Inferno::Repositories::TestSuites.new.find(test_suite_id)
89
- end
90
- end
91
74
  end
92
75
  end
93
76
  end
@@ -22,7 +22,7 @@ module Inferno
22
22
  return @default_group if @default_group
23
23
 
24
24
  @default_group = Class.new(TestGroup)
25
- groups << @default_group
25
+ children << @default_group
26
26
  @default_group
27
27
  end
28
28
 
@@ -31,7 +31,7 @@ module Inferno
31
31
  end
32
32
 
33
33
  def groups
34
- @groups ||= []
34
+ children.select { |child| child < Inferno::Entities::TestGroup }
35
35
  end
36
36
 
37
37
  # Methods to configure Inferno::DSL::Runnable
@@ -44,8 +44,7 @@ module Inferno
44
44
  def group_metadata
45
45
  {
46
46
  class: TestGroup,
47
- repo: Inferno::Repositories::TestGroups.new,
48
- collection: groups
47
+ repo: Inferno::Repositories::TestGroups.new
49
48
  }
50
49
  end
51
50
 
@@ -1,4 +1,5 @@
1
1
  require_relative 'entities/attributes'
2
+ require_relative 'entities/has_runnable'
2
3
  require_relative 'entities/entity'
3
4
  require_relative 'entities/header'
4
5
  require_relative 'entities/message'
@@ -62,5 +62,24 @@ module Inferno
62
62
  super('The chosen runnable must be run as part of a group')
63
63
  end
64
64
  end
65
+
66
+ class UnknownAttributeException < RuntimeError
67
+ def initialize(attributes, klass)
68
+ attributes_string = attributes.map { |attribute| "'#{attribute}'" }.join(', ')
69
+ super("Unknown attributes for #{klass.name}: #{attributes_string}")
70
+ end
71
+ end
72
+
73
+ class UnknownSessionDataType < RuntimeError
74
+ def initialize(output)
75
+ super("Unknown type '#{output[:type]}' for '#{output[:name]}'.")
76
+ end
77
+ end
78
+
79
+ class BadSessionDataType < RuntimeError
80
+ def initialize(name, expected_class, actual_class)
81
+ super("Expected '#{name}' to be a #{expected_class.name}, but found a #{actual_class.name}.")
82
+ end
83
+ end
65
84
  end
66
85
  end
@@ -0,0 +1,13 @@
1
+ module FHIR
2
+ class Client
3
+ attr_accessor :oauth_credentials
4
+
5
+ def need_to_refresh?
6
+ oauth_credentials&.need_to_refresh?
7
+ end
8
+
9
+ def able_to_refresh?
10
+ oauth_credentials&.able_to_refresh?
11
+ end
12
+ end
13
+ end