inferno_core 0.6.7 → 0.6.9

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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/lib/inferno/apps/cli/evaluate.rb +6 -1
  3. data/lib/inferno/apps/cli/templates/lib/%library_name%/suite.rb.tt +2 -2
  4. data/lib/inferno/apps/web/controllers/requirements/show.rb +18 -0
  5. data/lib/inferno/apps/web/controllers/test_suites/requirements/index.rb +29 -0
  6. data/lib/inferno/apps/web/router.rb +7 -0
  7. data/lib/inferno/apps/web/serializers/requirement.rb +18 -0
  8. data/lib/inferno/apps/web/serializers/requirement_set.rb +13 -0
  9. data/lib/inferno/apps/web/serializers/test_suite.rb +10 -0
  10. data/lib/inferno/config/boot/requirements.rb +40 -0
  11. data/lib/inferno/dsl/fhir_evaluation/rules/all_defined_extensions_have_examples.rb +58 -0
  12. data/lib/inferno/dsl/fhir_evaluation/rules/all_extensions_used.rb +76 -0
  13. data/lib/inferno/dsl/fhir_evaluation/rules/all_profiles_have_examples.rb +49 -0
  14. data/lib/inferno/dsl/fhir_evaluation/rules/all_search_parameters_have_examples.rb +79 -0
  15. data/lib/inferno/dsl/fhir_evaluation/rules/differential_content_has_examples.rb +124 -0
  16. data/lib/inferno/dsl/fhir_evaluation/rules/value_sets_demonstrate.rb +233 -0
  17. data/lib/inferno/dsl/fhir_resource_navigation.rb +11 -2
  18. data/lib/inferno/dsl/must_support_assessment.rb +15 -3
  19. data/lib/inferno/dsl/requirement_set.rb +82 -0
  20. data/lib/inferno/dsl/runnable.rb +22 -0
  21. data/lib/inferno/dsl/suite_requirements.rb +46 -0
  22. data/lib/inferno/entities/ig.rb +4 -0
  23. data/lib/inferno/entities/requirement.rb +63 -0
  24. data/lib/inferno/entities/test_suite.rb +2 -0
  25. data/lib/inferno/public/bundle.js +3 -3
  26. data/lib/inferno/repositories/igs.rb +1 -2
  27. data/lib/inferno/repositories/requirements.rb +116 -0
  28. data/lib/inferno/version.rb +1 -1
  29. metadata +17 -2
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+ require 'base64'
7
+
8
+ module Inferno
9
+ module DSL
10
+ module FHIREvaluation
11
+ module Rules
12
+ # This rule evaluates if an IG defines a new valueset, the examples should
13
+ # demonstrate reasonable coverage of that valueset.
14
+ # Note this probably only makes sense for small valuesets
15
+ # such as status options, not something like disease codes from SNOMED.
16
+
17
+ # Algorithm:
18
+ # 1. Extract pairs of system and code from include in value sets in IG
19
+ # 2. If valueSet exists in include, retrieve the value sets from UMLS.
20
+ # Extract pairs of system and code from the result.
21
+ # 3. For each pair of system and code, check if any resources in the IG have instance of them.
22
+ # 4. Count total number of existences.
23
+
24
+ class ValueSetsDemonstrate < Rule
25
+ attr_accessor :config, :value_set_unevaluated, :value_set_used, :value_set_unused
26
+
27
+ def check(context)
28
+ @config = context.config
29
+ @value_set_used = []
30
+ @value_set_unused = []
31
+ @value_set_unevaluated = []
32
+
33
+ classify_valuesets(context)
34
+
35
+ context.add_result create_result_message
36
+ end
37
+
38
+ # rubocop:disable Metrics/CyclomaticComplexity
39
+ def classify_valuesets(context)
40
+ context.ig.value_sets.each do |valueset|
41
+ valueset_used_count = 0
42
+ system_codes = extract_systems_codes_from_valueset(valueset)
43
+
44
+ value_set_unevaluated << "#{valueset.url}: unable to find system and code" if system_codes.none?
45
+ value_set_unevaluated.uniq!
46
+
47
+ next if value_set_unevaluated.any? { |element| element.include?(valueset.url) }
48
+
49
+ resource_used = []
50
+
51
+ context.data.each do |resource|
52
+ system_codes.each do |system_code|
53
+ next unless !system_code.nil? && resource_uses_code(resource.to_hash, system_code[:system],
54
+ system_code[:code])
55
+
56
+ valueset_used_count += 1
57
+ resource_used << resource.id unless resource_used.include?(resource.id)
58
+ end
59
+ end
60
+
61
+ if valueset_used_count.positive?
62
+ # rubocop:disable Layout/LineLength
63
+ value_set_used << "#{valueset.url} is used #{valueset_used_count} times in #{resource_used.count} resources"
64
+ # rubocop:enable Layout/LineLength
65
+ else
66
+ value_set_unused << valueset.url
67
+ end
68
+ end
69
+ end
70
+ # rubocop:enable Metrics/CyclomaticComplexity
71
+
72
+ # rubocop:disable Metrics/CyclomaticComplexity
73
+ def create_result_message
74
+ if value_set_unused.none?
75
+ message = 'All Value sets are used in Examples:'
76
+ value_set_used.map { |value_set| message += "\n\t#{value_set}" }
77
+
78
+ if value_set_unevaluated.any?
79
+ message += "\nThe following Value Sets were not able to be evaluated: "
80
+ value_set_unevaluated.map { |value_set| message += "\n\t#{value_set}" }
81
+ end
82
+
83
+ EvaluationResult.new(message, severity: 'success', rule: self)
84
+ else
85
+ message = 'Value sets with all codes used at least once in Examples:'
86
+ value_set_used.map { |url| message += "\n\t#{url}" }
87
+
88
+ message += "\nFound unused Value Sets: "
89
+ value_set_unused.map { |url| message += "\n\t#{url}" }
90
+
91
+ if value_set_unevaluated.any?
92
+ message += "\nFound unevaluated Value Sets: "
93
+ value_set_unevaluated.map { |url| message += "\n\t#{url}" }
94
+ end
95
+
96
+ EvaluationResult.new(message, rule: self)
97
+ end
98
+ end
99
+ # rubocop:enable Metrics/CyclomaticComplexity
100
+
101
+ # rubocop:disable Metrics/CyclomaticComplexity
102
+ def extract_systems_codes_from_valueset(valueset)
103
+ system_codes = []
104
+
105
+ if valueset.to_hash['compose']
106
+ valueset.to_hash['compose']['include'].each do |include|
107
+ if include['valueSet']
108
+ include['valueSet'].each do |url|
109
+ retrieve_valueset_from_api(url)&.each { |system_code| system_codes << system_code }
110
+ end
111
+ next
112
+ end
113
+
114
+ system_url = include['system']
115
+
116
+ if system_url && include['concept']
117
+ include['concept'].each do |code|
118
+ system_codes << { system: system_url, code: code.to_hash['code'] }
119
+ end
120
+ next
121
+ end
122
+
123
+ if system_url
124
+ if system_url['http://hl7.org/fhir']
125
+ retrieve_valueset_from_api(system_url)&.each { |vs| system_codes << vs }
126
+ end
127
+ next
128
+ end
129
+
130
+ value_set_unevaluated << "#{valueset.url}: system url not provided" unless system_url
131
+
132
+ # Exclude if system is provided as Uniform Resource Name "urn:"
133
+ # Exclude filter
134
+ # Exclude only system is provided (e.g. http://loing.org)
135
+ exclusions = config.data['Rule']['ValueSetsDemonstrate']['Exclude']
136
+ if exclusions['URL'] && (system_url['urn'])
137
+ value_set_unevaluated << "#{valueset.url}: unable to handle Uniform Resource Name"
138
+ end
139
+
140
+ if exclusions['Filter'] && (system_url && include['filter'])
141
+ value_set_unevaluated << "#{valueset.url}: unable to handle filter"
142
+ end
143
+
144
+ if exclusions['SystemOnly'] && (system_url && !include['concept'] && !include['filter'])
145
+ value_set_unevaluated << "#{valueset.url}: unabe to handle SystemOnly"
146
+ end
147
+ end
148
+ else
149
+ value_set_unevaluated << valueset.url
150
+ end
151
+ system_codes.flatten.uniq
152
+ end
153
+ # rubocop:enable Metrics/CyclomaticComplexity
154
+
155
+ # rubocop:disable Metrics/CyclomaticComplexity
156
+ def resource_uses_code(resource, system, code)
157
+ resource.each do |key, value|
158
+ next unless key == 'code' || ['value', 'valueCodeableConcept', 'valueString',
159
+ 'valueQuantity', 'valueBoolean',
160
+ 'valueInteger', 'valueRange', 'valueRatio',
161
+ 'valueSampleData', 'valueDateTime',
162
+ 'valuePeriod', 'valueTime'].include?(key)
163
+ next unless value.is_a?(Hash)
164
+
165
+ value['coding']&.each do |codeset|
166
+ return true if codeset.to_hash['system'] == system && codeset.to_hash['code'] == code
167
+ end
168
+ end
169
+
170
+ false
171
+ end
172
+ # rubocop:enable Metrics/CyclomaticComplexity
173
+
174
+ def extract_valueset_from_response(response)
175
+ value_set = JSON.parse(response.body)
176
+
177
+ if value_set['compose'] && value_set['compose']['include']
178
+ value_set['compose']['include'].map do |include|
179
+ include['concept']&.map { |concept| { system: include['system'], code: concept['code'] } }
180
+ end.flatten
181
+ else
182
+ puts 'No Value Set found in the response.'
183
+ end
184
+ end
185
+
186
+ def retrieve_valueset_from_api(url)
187
+ url['http:'] = 'https:' if url['http:']
188
+ uri = URI.parse(url)
189
+
190
+ http = Net::HTTP.new(uri.host, uri.port)
191
+ http.use_ssl = (uri.scheme == 'https')
192
+
193
+ request = Net::HTTP::Get.new(uri.request_uri)
194
+
195
+ username = config.data['Environment']['VSAC']['Username']
196
+ password = config.data['Environment']['VSAC']['Password']
197
+ encoded_credentials = Base64.strict_encode64("#{username}:#{password}")
198
+ request['Authorization'] = "Basic #{encoded_credentials}"
199
+
200
+ response = http.request(request)
201
+
202
+ content_type = response['content-type']
203
+ return unless content_type && !content_type.include?('text/html')
204
+
205
+ while response.is_a?(Net::HTTPRedirection)
206
+ redirect_url = response['location']
207
+
208
+ redirect_url['xml'] = 'json'
209
+ uri = URI.parse(redirect_url)
210
+
211
+ http = Net::HTTP.new(uri.host, uri.port)
212
+ http.use_ssl = (uri.scheme == 'https')
213
+
214
+ response = http.request(Net::HTTP::Get.new(uri.request_uri))
215
+ end
216
+
217
+ if response.code.to_i == 200
218
+ extract_valueset_from_response(response)
219
+ else
220
+ unless config.data['Rule']['ValueSetsDemonstrate']['IgnoreUnloadableValueset']
221
+ raise StandardError, "Failed to retrieve external value set: #{url} HTTP Status code: #{response.code}"
222
+ end
223
+
224
+ value_set_unevaluated << "#{url}: Failed to retrieve. HTTP Status code: #{response.code}"
225
+ nil
226
+
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
@@ -177,8 +177,17 @@ module Inferno
177
177
  end
178
178
 
179
179
  def matching_required_binding_slice?(slice, discriminator)
180
- discriminator[:path].present? ? slice.send((discriminator[:path]).to_s).coding : slice.coding
181
- slice_value { |coding| discriminator[:values].include?(coding.code) }
180
+ slice_coding = discriminator[:path].present? ? slice.send((discriminator[:path]).to_s).coding : slice.coding
181
+ slice_coding.any? do |coding|
182
+ discriminator[:values].any? do |value|
183
+ case value
184
+ when String
185
+ value == coding.code
186
+ when Hash
187
+ value[:system] == coding.system && value[:code] == coding.code
188
+ end
189
+ end
190
+ end
182
191
  end
183
192
 
184
193
  def verify_slice_by_values(element, value_definitions)
@@ -332,18 +332,23 @@ module Inferno
332
332
  when 'Date'
333
333
  begin
334
334
  Date.parse(element)
335
- rescue ArgumentError
335
+ rescue ArgumentError, TypeError
336
336
  false
337
337
  end
338
338
  when 'DateTime'
339
339
  begin
340
340
  DateTime.parse(element)
341
- rescue ArgumentError
341
+ rescue ArgumentError, TypeError
342
342
  false
343
343
  end
344
344
  when 'String'
345
345
  element.is_a? String
346
346
  else
347
+ if element.is_a? FHIR::Bundle::Entry
348
+ # Special case for type slicing in a Bundle - look at the resource not the entry
349
+ element = element.resource
350
+ end
351
+
347
352
  element.is_a? FHIR.const_get(discriminator[:code])
348
353
  end
349
354
  end
@@ -352,7 +357,14 @@ module Inferno
352
357
  coding_path = discriminator[:path].present? ? "#{discriminator[:path]}.coding" : 'coding'
353
358
 
354
359
  find_a_value_at(element, coding_path) do |coding|
355
- discriminator[:values].any? { |value| value[:system] == coding.system && value[:code] == coding.code }
360
+ discriminator[:values].any? do |value|
361
+ case value
362
+ when String
363
+ value == coding.code
364
+ when Hash
365
+ value[:system] == coding.system && value[:code] == coding.code
366
+ end
367
+ end
356
368
  end
357
369
  end
358
370
 
@@ -0,0 +1,82 @@
1
+ module Inferno
2
+ module DSL
3
+ # A `RequirementSet` represents the set of requirements which are tested by
4
+ # a TestSuite.
5
+ #
6
+ # @!attribute identifier [rw] The unique identifier for the source of
7
+ # requirements included in this `RequirementSet`
8
+ # @!attribute title [rw] A human-readable title for this `RequirementSet`
9
+ # @!attribute actor [rw] The actor whose requirements are included in this
10
+ # `RequirementSet`
11
+ # @!attribute requirements [rw] There are three options:
12
+ # * `"all"` (default) - Include all of the requirements for the specified
13
+ # actor from the requirement source
14
+ # * `"referenced"` - Only include requirements from this source if they
15
+ # are referenced by other included requirements
16
+ # * `"1,3,5-8"` - Only include the requirements from a comma-delimited
17
+ # list
18
+ # @!attribute suite_options [rw] A set of suite options which must be
19
+ # selected in order for this `RequirementSet` to be included
20
+ #
21
+ # @see Inferno::DSL::SuiteRequirements#requirement_sets
22
+ class RequirementSet
23
+ ATTRIBUTES = [
24
+ :identifier,
25
+ :title,
26
+ :actor,
27
+ :requirements,
28
+ :suite_options
29
+ ].freeze
30
+
31
+ include Entities::Attributes
32
+
33
+ def initialize(raw_attributes_hash)
34
+ attributes_hash = raw_attributes_hash.symbolize_keys
35
+
36
+ invalid_keys = attributes_hash.keys - ATTRIBUTES
37
+
38
+ raise Exceptions::UnknownAttributeException.new(invalid_keys, self.class) if invalid_keys.present?
39
+
40
+ attributes_hash.each do |name, value|
41
+ if name == :suite_options
42
+ value = value&.map { |option_id, option_value| SuiteOption.new(id: option_id, value: option_value) }
43
+ end
44
+
45
+ instance_variable_set(:"@#{name}", value)
46
+ end
47
+
48
+ self.suite_options ||= []
49
+ end
50
+
51
+ # Returns true when the `RequirementSet` includes all of the requirements
52
+ # from the source for the specified actor
53
+ #
54
+ # @return [Boolean]
55
+ def complete?
56
+ requirements.blank? || requirements.casecmp?('all')
57
+ end
58
+
59
+ # Returns true when the `RequirementSet` only includes requirements
60
+ # referenced by other `RequirementSet`s
61
+ #
62
+ # @return [Boolean]
63
+ def referenced?
64
+ requirements&.casecmp? 'referenced'
65
+ end
66
+
67
+ # Returns true when the `RequirementSet` only includes requirements
68
+ # specified in a list
69
+ #
70
+ # @return [Boolean]
71
+ def filtered?
72
+ !complete? && !referenced?
73
+ end
74
+
75
+ # Expands the compressed comma-separated requirements list into an Array
76
+ # of full ids
77
+ def expand_requirement_ids
78
+ Entities::Requirement.expand_requirement_ids(requirements, identifier)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -276,6 +276,21 @@ module Inferno
276
276
  @input_instructions = format_markdown(new_input_instructions)
277
277
  end
278
278
 
279
+ # Set/Get the IDs of requirements verified by this runnable
280
+ # Set with [] to clear the list
281
+ #
282
+ # @param requirements [Array<String>]
283
+ # @return [Array<String>] the requirement IDs
284
+ def verifies_requirements(*requirement_ids)
285
+ if requirement_ids.empty?
286
+ @requirement_ids || []
287
+ elsif requirement_ids == [[]]
288
+ @requirement_ids = []
289
+ else
290
+ @requirement_ids = requirement_ids
291
+ end
292
+ end
293
+
279
294
  # Mark as optional. Tests are required by default.
280
295
  #
281
296
  # @param optional [Boolean]
@@ -550,6 +565,13 @@ module Inferno
550
565
  end
551
566
  end
552
567
 
568
+ # @private
569
+ def all_requirements(suite_options = [])
570
+ children(suite_options).flat_map do |child|
571
+ child.verifies_requirements + child.all_requirements(suite_options)
572
+ end
573
+ end
574
+
553
575
  # @private
554
576
  def inspect
555
577
  non_dynamic_ancestor = ancestors.find { |ancestor| !ancestor.to_s.start_with? '#' }
@@ -0,0 +1,46 @@
1
+ require_relative 'requirement_set'
2
+
3
+ module Inferno
4
+ module DSL
5
+ module SuiteRequirements
6
+ # Get/Set the sets of requirments tested by a suite.
7
+ #
8
+ # @param sets [Array<Inferno::DSL::RequirementSet>]
9
+ # @return [Array<Inferno::DSL::RequirementSet>]
10
+ #
11
+ # @example
12
+ # class Suite < Inferno::TestSuite
13
+ # requirement_sets(
14
+ # {
15
+ # identifier: 'example-regulation-1',
16
+ # title: 'Example Regulation 1',
17
+ # actor: 'Provider' # Only include requirements for the 'Provider'
18
+ # # actor
19
+ # },
20
+ # {
21
+ # identifier: 'example-ig-1',
22
+ # title: 'Example Implementation Guide 1',
23
+ # actor: 'Provider',
24
+ # requirements: '2, 4-5' # Only include these specific requirements
25
+ # },
26
+ # {
27
+ # identifier: 'example-ig-2',
28
+ # title: 'Example Implementation Guide 2',
29
+ # requirements: 'Referenced', # Only include requirements from this
30
+ # # set that are referenced by other
31
+ # # included requirements
32
+ # actor: 'Server',
33
+ # suite_options: { # Only include these requirements if the ig
34
+ # ig_version: '3.0.0' # version 3.0.0 suite option is selected
35
+ # }
36
+ # }
37
+ # )
38
+ # end
39
+ def requirement_sets(*sets)
40
+ @requirement_sets = sets.map { |set| RequirementSet.new(**set) } if sets.present?
41
+
42
+ @requirement_sets || []
43
+ end
44
+ end
45
+ end
46
+ end
@@ -126,6 +126,10 @@ module Inferno
126
126
  "#{ig_resource.id}##{ig_resource.version || 'current'}"
127
127
  end
128
128
 
129
+ def value_sets
130
+ resources_by_type['ValueSet']
131
+ end
132
+
129
133
  def profiles
130
134
  resources_by_type['StructureDefinition'].filter { |sd| sd.type != 'Extension' }
131
135
  end
@@ -0,0 +1,63 @@
1
+ require_relative 'attributes'
2
+ require_relative 'entity'
3
+
4
+ module Inferno
5
+ module Entities
6
+ # A `Requirement` represents the specific rule or behavior a runnable is testing.
7
+ class Requirement < Entity
8
+ ATTRIBUTES = [
9
+ :id,
10
+ :requirement_set,
11
+ :url,
12
+ :requirement,
13
+ :conformance,
14
+ :actor,
15
+ :sub_requirements,
16
+ :conditionality
17
+ ].freeze
18
+
19
+ include Inferno::Entities::Attributes
20
+
21
+ def initialize(params)
22
+ super(params, ATTRIBUTES)
23
+
24
+ self.requirement_set = id.split('@').first if requirement_set.blank? && id&.include?('@')
25
+ end
26
+
27
+ # Expand a comma-delimited list of requirement id references into an Array
28
+ # of full requirement ids
29
+ #
30
+ # @param requirement_id_string [String] A comma-delimited list of
31
+ # requirement id references
32
+ # @param default_set [String] The requirement set identifier which will be
33
+ # used if none is included in the `requirement_id_string`
34
+ #
35
+ # @example
36
+ # expand_requirement_ids('example-ig@1,3,5-7')
37
+ # # => ['example-ig@1','example-ig@3','example-ig@5','example-ig@6','example-ig@7']
38
+ # expand_requirement_ids('1,3,5-7', 'example-ig')
39
+ # # => ['example-ig@1','example-ig@3','example-ig@5','example-ig@6','example-ig@7']
40
+ def self.expand_requirement_ids(requirement_id_string, default_set = nil)
41
+ return [] if requirement_id_string.blank?
42
+
43
+ current_set = default_set
44
+ requirement_id_string
45
+ .split(',')
46
+ .map(&:strip)
47
+ .flat_map do |requirement_string|
48
+ current_set, requirement_string = requirement_string.split('@') if requirement_string.include?('@')
49
+
50
+ requirement_ids =
51
+ if requirement_string.include? '-'
52
+ start_id, end_id = requirement_string.split('-')
53
+ (start_id..end_id).to_a
54
+ else
55
+ [requirement_string]
56
+ end
57
+
58
+ requirement_ids.map { |id| "#{current_set}@#{id}" }
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -1,6 +1,7 @@
1
1
  require_relative 'test_group'
2
2
  require_relative '../dsl/runnable'
3
3
  require_relative '../dsl/suite_option'
4
+ require_relative '../dsl/suite_requirements'
4
5
  require_relative '../dsl/messages'
5
6
  require_relative '../dsl/links'
6
7
  require_relative '../repositories/test_groups'
@@ -16,6 +17,7 @@ module Inferno
16
17
  extend DSL::Links
17
18
  extend DSL::FHIRClient::ClassMethods
18
19
  extend DSL::HTTPClient::ClassMethods
20
+ extend DSL::SuiteRequirements
19
21
  include DSL::FHIRValidation
20
22
  include DSL::FHIRResourceValidation
21
23
  include DSL::FhirpathEvaluation