inferno_core 0.6.8 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6daa4c1864a7e7004ca9a166f3e516fcddc157c3f6b6e24722965e44330cc450
4
- data.tar.gz: b158c90f63eaa8a157a27b92af68b7a3be7798e18aca2243ecbd1071c9d9bfdf
3
+ metadata.gz: 5740a0c7e8d54767851b4f7e9ee7c5c6ca9ac175211d63b4990f99bbbe9266ca
4
+ data.tar.gz: 4fd1b9c5cb4f37c441a668ae58c8b6cd49aeea18ad393b2d7bbb9699d86a0060
5
5
  SHA512:
6
- metadata.gz: 6298ba6c1f61a6ee3f5e50045ac58e1ebaa63a2ebc863755d1c610d3eed4b0e7ca76ea74ee12c506298624248c5f3b874582eaccc1c41600f731b33a601d72ce
7
- data.tar.gz: 61f9b65616cf01dd42c838ab0b9be5c38490dea22bb2a3481a82c30a25c687e2aada97321498920f6219a62afe88445ad95e71b2300859fce5ed19af7cef1f78
6
+ metadata.gz: f6adc91f5db1ba2a6535c847c0fe7b691cd85455fb0f34a44bd525428dee3ddcd4c414d7989ea4e13fdd50b9c39778a19d26e97473f1617b85691b4683776f49
7
+ data.tar.gz: 463d68cf3e187f992bd134bbc177072bfaa9ec625a2006ca1bb23c49591a4bc9521ca027232c68c5d1ebcf499dcca84f3f722ca2afd9bd1b6038bbb19daf4835
@@ -13,13 +13,13 @@ module <%= module_name %>
13
13
 
14
14
  input :credentials,
15
15
  title: 'OAuth Credentials',
16
- type: :oauth_credentials,
16
+ type: :auth_info,
17
17
  optional: true
18
18
 
19
19
  # All FHIR requests in this suite will use this FHIR client
20
20
  fhir_client do
21
21
  url :url
22
- oauth_credentials :credentials
22
+ auth_info :credentials
23
23
  end
24
24
 
25
25
  # All FHIR validation requests will use this FHIR validator
@@ -0,0 +1,18 @@
1
+ require_relative '../../serializers/requirement'
2
+
3
+ module Inferno
4
+ module Web
5
+ module Controllers
6
+ module Requirements
7
+ class Show < Controller
8
+ def handle(req, res)
9
+ requirement = repo.find(req.params[:id])
10
+ halt 404 if requirement.nil?
11
+
12
+ res.body = serialize(requirement)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,29 @@
1
+ module Inferno
2
+ module Web
3
+ module Controllers
4
+ module TestSuites
5
+ module Requirements
6
+ class Index < Controller
7
+ include Import[test_suites_repo: 'inferno.repositories.test_suites']
8
+ include Import[test_sessions_repo: 'inferno.repositories.test_sessions']
9
+
10
+ def handle(req, res)
11
+ test_suite = test_suites_repo.find(req.params[:id])
12
+ halt 404, "Test Suite `#{req.params[:id]}` not found" if test_suite.nil?
13
+
14
+ test_session = nil
15
+ if req.params[:session_id]
16
+ test_session = test_sessions_repo.find(req.params[:session_id])
17
+ halt 404, "Test session `#{req.params[:session_id]}` not found" if test_session.nil?
18
+ end
19
+
20
+ requirements = repo.requirements_for_suite(test_suite.id, test_session&.id)
21
+
22
+ res.body = serialize(requirements)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -47,6 +47,13 @@ module Inferno
47
47
  put '/:id/check_configuration',
48
48
  to: Inferno::Web::Controllers::TestSuites::CheckConfiguration,
49
49
  as: :check_configuration
50
+ get ':id/requirements',
51
+ to: Inferno::Web::Controllers::TestSuites::Requirements::Index,
52
+ as: :requirements
53
+ end
54
+
55
+ scope 'requirements' do
56
+ get '/:id', to: Inferno::Web::Controllers::Requirements::Show, as: :show
50
57
  end
51
58
 
52
59
  get '/requests/:id', to: Inferno::Web::Controllers::Requests::Show, as: :requests_show
@@ -0,0 +1,18 @@
1
+ require_relative 'serializer'
2
+
3
+ module Inferno
4
+ module Web
5
+ module Serializers
6
+ class Requirement < Serializer
7
+ identifier :id
8
+
9
+ field :requirement
10
+ field :conformance
11
+ field :actor
12
+ field :sub_requirements
13
+ field :conditionality
14
+ field :url, if: :field_present?
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ require_relative 'serializer'
2
+
3
+ module Inferno
4
+ module Web
5
+ module Serializers
6
+ class RequirementSet < Serializer
7
+ identifier :identifier
8
+
9
+ field :title
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,4 +1,5 @@
1
1
  require_relative 'preset'
2
+ require_relative 'requirement_set'
2
3
  require_relative 'suite_option'
3
4
  require_relative 'test_group'
4
5
 
@@ -27,6 +28,7 @@ module Inferno
27
28
 
28
29
  view :full do
29
30
  include_view :summary
31
+
30
32
  field :test_groups do |suite, options|
31
33
  suite_options = options[:suite_options]
32
34
  TestGroup.render_as_hash(suite.groups(suite_options), suite_options:)
@@ -36,6 +38,14 @@ module Inferno
36
38
  suite_options = options[:suite_options]
37
39
  Input.render_as_hash(suite.available_inputs(suite_options).values)
38
40
  end
41
+ field :requirement_sets, if: :field_present? do |suite, options|
42
+ selected_options = options[:suite_options] || []
43
+ requirement_sets = suite.requirement_sets.select do |requirement_set|
44
+ requirement_set.suite_options.all? { |suite_option| selected_options.include? suite_option }
45
+ end
46
+
47
+ RequirementSet.render_as_hash(requirement_sets)
48
+ end
39
49
  end
40
50
  end
41
51
  end
@@ -0,0 +1,40 @@
1
+ require_relative '../../repositories/requirements'
2
+
3
+ Inferno::Application.register_provider(:requirements) do
4
+ prepare do
5
+ target_container.start :suites
6
+
7
+ requirements_repo = Inferno::Repositories::Requirements.new
8
+
9
+ test_kit_gems =
10
+ Bundler
11
+ .definition
12
+ .specs
13
+ .select { |spec| spec.metadata.fetch('inferno_test_kit', 'false').casecmp? 'true' }
14
+
15
+ files_to_load = Dir.glob(['lib/*test_kit/requirements/*.csv'])
16
+
17
+ if ENV['LOAD_DEV_SUITES'].present?
18
+ ENV['LOAD_DEV_SUITES'].split(',').map(&:strip).reject(&:empty?).each do |suite|
19
+ files_to_load.concat Dir.glob(File.join(Inferno::Application.root, 'dev_suites', suite, 'requirements',
20
+ '*.csv'))
21
+ end
22
+ end
23
+
24
+ files_to_load +=
25
+ test_kit_gems.flat_map do |gem|
26
+ [
27
+ Dir.glob([File.join(gem.full_gem_path, 'lib', '*test_kit', 'requirements', '*.csv')])
28
+ ].flatten
29
+ end
30
+
31
+ files_to_load.compact!
32
+ files_to_load.reject! { |file| file.include?('out_of_scope') }
33
+ files_to_load.map! { |path| File.realpath(path) }
34
+ files_to_load.uniq!
35
+
36
+ files_to_load.each do |path|
37
+ requirements_repo.insert_from_file(path)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inferno
4
+ module DSL
5
+ module FHIREvaluation
6
+ module Rules
7
+ class AllExtensionsUsed < Rule
8
+ def check(context)
9
+ all_extensions = collect_profile_extensions(context.ig.profiles)
10
+ unused_extensions = remove_found_resource_extensions(all_extensions, context.data)
11
+ if unused_extensions.any? { |_profile, extensions| !extensions.empty? }
12
+ message = get_fail_message(unused_extensions)
13
+ result = EvaluationResult.new(message, rule: self)
14
+ else
15
+ message = 'All extensions specified in profiles are represented in instances.'
16
+ result = EvaluationResult.new(message, severity: 'success', rule: self)
17
+ end
18
+
19
+ context.add_result result
20
+ end
21
+
22
+ def collect_profile_extensions(profiles)
23
+ extensions = Hash.new { |extension, profile| extension[profile] = Set.new }
24
+ profiles.each do |profile|
25
+ profile.each_element do |value, metadata|
26
+ next unless metadata['type'] == 'ElementDefinition'
27
+
28
+ path_end = value.id.split('.')[-1]
29
+ next unless path_end.include?('extension')
30
+
31
+ value.type.each do |element_definition|
32
+ element_definition.profile.each do |extension_url|
33
+ extensions[profile.url].add(extension_url)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ extensions
39
+ end
40
+
41
+ # rubocop:disable Metrics/CyclomaticComplexity
42
+ def remove_found_resource_extensions(extensions, examples)
43
+ unused_extensions = extensions.dup
44
+ examples.each do |resource|
45
+ resource.each_element do |value, _metadata, path|
46
+ path_elements = path.split('.')
47
+ next unless path_elements.length > 1
48
+
49
+ next unless path_elements[-2].include?('extension') && path_elements[-1] == 'url'
50
+
51
+ profiles = resource&.meta&.profile || []
52
+ update_unused_extensions(profiles, value, unused_extensions, extensions)
53
+ end
54
+ end
55
+ unused_extensions
56
+ end
57
+ # rubocop:enable Metrics/CyclomaticComplexity
58
+
59
+ def update_unused_extensions(profiles, value, unused_extensions, extensions)
60
+ profiles.each do |profile|
61
+ unused_extensions[profile].delete(value) if extensions.key?(profile)
62
+ end
63
+ end
64
+
65
+ def get_fail_message(extensions)
66
+ message = 'Found extensions specified in profiles, but not used in instances:'
67
+ extensions.each do |profile, extension|
68
+ message += "\n Profile: #{profile}, \n\tExtensions: #{extension.join(', ')}" unless extension.empty?
69
+ end
70
+ message
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inferno
4
+ module DSL
5
+ module FHIREvaluation
6
+ module Rules
7
+ class DifferentialContentHasExamples < Rule
8
+ def check(context)
9
+ unused_differential = Hash.new { |field, url| field[url] = Set.new }
10
+ collect_profile_differential_content(unused_differential, context.ig.profiles)
11
+ collect_profile_differential_content(unused_differential, context.ig.extensions)
12
+ remove_found_differential_content(unused_differential, context.data)
13
+
14
+ if unused_differential.any? { |_url, diff| !diff.empty? }
15
+ message = gen_differential_fail_message(unused_differential)
16
+ result = EvaluationResult.new(message, rule: self)
17
+ else
18
+ message = 'All differential fields are represented in instances'
19
+ result = EvaluationResult.new(message, severity: 'success', rule: self)
20
+ end
21
+
22
+ context.add_result result
23
+ end
24
+
25
+ def collect_profile_differential_content(unused_differential, profiles)
26
+ profiles.each do |profile|
27
+ profile.each_element do |value, _metadata, path|
28
+ next unless path.start_with? 'differential'
29
+
30
+ next unless value.is_a? FHIR::ElementDefinition
31
+ next unless value.id.include? '.'
32
+
33
+ # Skip fields that are disallowed by the profile (cardinality 0..0)
34
+ # Note that max is a string to allow for "*", not an int
35
+ next if value.max == '0'
36
+
37
+ # TODO: discriminate between extensions
38
+ # if you have field.extension:A and field.extension:B
39
+ # only field.extension is recorded and checked for
40
+ # if A and B are not defined in the IG,they may be missed
41
+ clean_val = clean_value(value)
42
+
43
+ unused_differential[profile.url].add(clean_val)
44
+ end
45
+ end
46
+ end
47
+
48
+ # rubocop:disable Metrics/CyclomaticComplexity
49
+ def remove_found_differential_content(unused_differential, examples)
50
+ examples.each do |resource|
51
+ extension_base_path = ''
52
+ extension_url = ''
53
+ resource.each_element do |value, _metadata, path|
54
+ profiles = resource&.meta&.profile || []
55
+ profiles.each do |profile|
56
+ processed_path = plain_value(path)
57
+ processed_path = rm_brackets(processed_path)
58
+
59
+ if path.match?('extension\[\d+\]\.url$')
60
+ extension_base_path = path.rpartition('.').first
61
+ extension_url = value
62
+ unused_differential[extension_url].delete('url') if unused_differential.key?(extension_url)
63
+ unused_differential[extension_url].delete('extension') if unused_differential.key?(extension_url)
64
+ unused_differential.delete(extension_url) if unused_differential[extension_url].empty?
65
+ elsif path.start_with?(extension_base_path) && !extension_base_path.empty?
66
+ if unused_differential.key?(extension_url)
67
+ unused_differential[extension_url].delete(processed_path.rpartition('.').last)
68
+ end
69
+ unused_differential.delete(extension_url) if unused_differential[extension_url].empty?
70
+ else
71
+ unused_differential[profile].delete(processed_path) if unused_differential.key?(profile)
72
+ unused_differential.delete(profile) if unused_differential[profile].empty?
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ # rubocop:enable Metrics/CyclomaticComplexity
79
+
80
+ def clean_value(value)
81
+ stripped_val = value.id.partition('.').last
82
+ stripped_val = stripped_val.partition('[').first if stripped_val.end_with? ']'
83
+ stripped_val.split('.').map do |field|
84
+ field = field.partition(':').first if field.include?(':')
85
+ field = field.partition('[').first if field.include?('[')
86
+ field
87
+ end.join('.')
88
+ end
89
+
90
+ def plain_value(path)
91
+ if path.include? '.'
92
+ path_array = path.split('.').map! do |field|
93
+ field.start_with?('value') ? 'value' : field
94
+ end
95
+ path_array.join('.')
96
+ elsif path.start_with?('value')
97
+ 'value'
98
+ elsif path.end_with?(']')
99
+ path.rpartition('[').first
100
+ else
101
+ path
102
+ end
103
+ end
104
+
105
+ def rm_brackets(path)
106
+ path_array = path.split('.').map! do |field|
107
+ field.include?('[') ? field.partition('[').first : field
108
+ end
109
+ path_array.join('.')
110
+ end
111
+
112
+ def gen_differential_fail_message(unused_differential)
113
+ "Found fields highlighted in the differential view, but not used in instances: #{
114
+ unused_differential.map do |url, field|
115
+ next if field.empty?
116
+
117
+ "\n Profile/Extension: #{url} \n\tFields: #{field.join(', ')}"
118
+ end.compact.join}"
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -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
@@ -18,8 +18,7 @@ module Inferno
18
18
  def find_or_load(id_or_path)
19
19
  return find(id_or_path) if exists?(id_or_path)
20
20
 
21
- ig_by_path = find_by_path(id_or_path)
22
-
21
+ ig_by_path = find_by_path(id_or_path) || find_by_path(find_local_file(id_or_path))
23
22
  return ig_by_path if ig_by_path
24
23
 
25
24
  load(id_or_path)
@@ -0,0 +1,116 @@
1
+ require 'csv'
2
+ require_relative 'in_memory_repository'
3
+ require_relative '../entities/requirement'
4
+
5
+ module Inferno
6
+ module Repositories
7
+ # Repository that deals with persistence for the `Requirement` entity.
8
+ class Requirements < InMemoryRepository
9
+ def insert_from_file(path)
10
+ result = []
11
+
12
+ CSV.foreach(path, headers: true, header_converters: :symbol) do |row|
13
+ req_set = row[:req_set]
14
+ id = row[:id]
15
+ sub_requirements_field = row[:subrequirements]
16
+
17
+ combined_id = "#{req_set}@#{id}"
18
+
19
+ # Processing sub requirements: e.g. "170.315(g)(31)_hti-2-proposal@5,17,23,26,27,32,35,38-41"
20
+ sub_requirements = Inferno::Entities::Requirement.expand_requirement_ids(sub_requirements_field)
21
+
22
+ result << {
23
+ requirement_set: req_set,
24
+ id: combined_id,
25
+ url: row[:url],
26
+ requirement: row[:requirement],
27
+ conformance: row[:conformance],
28
+ actor: row[:actor],
29
+ sub_requirements: sub_requirements,
30
+ conditionality: row[:conditionality]&.downcase
31
+ }
32
+ end
33
+
34
+ result.each do |raw_req|
35
+ requirement = Entities::Requirement.new(raw_req)
36
+
37
+ insert(requirement)
38
+ end
39
+ end
40
+
41
+ def filter_requirements_by_ids(ids)
42
+ all.select { |requirement| ids.include?(requirement.id) }
43
+ end
44
+
45
+ def requirements_for_suite(test_suite_id, test_session_id = nil)
46
+ test_suite = Inferno::Repositories::TestSuites.new.find(test_suite_id)
47
+ selected_suite_options =
48
+ if test_session_id.present?
49
+ Inferno::Repositories::TestSessions.new.find(test_session_id).suite_options
50
+ else
51
+ []
52
+ end
53
+
54
+ requirement_sets =
55
+ test_suite
56
+ .requirement_sets
57
+ .select do |set|
58
+ set.suite_options.all? { |set_option| selected_suite_options.include? set_option }
59
+ end
60
+
61
+ requirements =
62
+ complete_requirement_set_requirements(requirement_sets) +
63
+ filtered_requirement_set_requirements(requirement_sets)
64
+
65
+ add_referenced_requirement_set_requirements(requirements, requirement_sets).uniq
66
+ end
67
+
68
+ def complete_requirement_set_requirements(requirement_sets)
69
+ requirement_sets.select(&:complete?)
70
+ .flat_map do |requirement_set|
71
+ all.select do |requirement|
72
+ requirement.requirement_set == requirement_set.identifier && requirement.actor == requirement_set.actor
73
+ end
74
+ end
75
+ end
76
+
77
+ def filtered_requirement_set_requirements(requirement_sets)
78
+ requirement_sets.select(&:filtered?)
79
+ .flat_map do |requirement_set|
80
+ requirement_set
81
+ .expand_requirement_ids
82
+ .map { |requirement_id| find(requirement_id) }
83
+ .select { |requirement| requirement.actor == requirement_set.actor }
84
+ end
85
+ end
86
+
87
+ def add_referenced_requirement_set_requirements( # rubocop:disable Metrics/CyclomaticComplexity
88
+ requirements_to_process,
89
+ requirement_sets,
90
+ processed_requirements = []
91
+ )
92
+ return processed_requirements if requirements_to_process.blank?
93
+
94
+ referenced_requirement_sets = requirement_sets.select(&:referenced?)
95
+
96
+ referenced_requirement_ids =
97
+ requirements_to_process
98
+ .flat_map(&:sub_requirements)
99
+ .select do |requirement_id|
100
+ referenced_requirement_sets.any? do |set|
101
+ requirement_id.start_with?("#{set.identifier}@") && (find(requirement_id).actor == set.actor)
102
+ end
103
+ end
104
+
105
+ new_requirements =
106
+ referenced_requirement_ids.map { |id| find(id) } - requirements_to_process - processed_requirements
107
+
108
+ add_referenced_requirement_set_requirements(
109
+ new_requirements,
110
+ referenced_requirement_sets,
111
+ (processed_requirements + requirements_to_process).uniq
112
+ )
113
+ end
114
+ end
115
+ end
116
+ end
@@ -1,4 +1,4 @@
1
1
  module Inferno
2
2
  # Standard patterns for gem versions: https://guides.rubygems.org/patterns/
3
- VERSION = '0.6.8'.freeze
3
+ VERSION = '0.6.9'.freeze
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: inferno_core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.8
4
+ version: 0.6.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephen MacVicar
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2025-03-28 00:00:00.000000000 Z
13
+ date: 2025-04-18 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: activesupport
@@ -465,6 +465,7 @@ files:
465
465
  - lib/inferno/apps/web/application.rb
466
466
  - lib/inferno/apps/web/controllers/controller.rb
467
467
  - lib/inferno/apps/web/controllers/requests/show.rb
468
+ - lib/inferno/apps/web/controllers/requirements/show.rb
468
469
  - lib/inferno/apps/web/controllers/test_runs/create.rb
469
470
  - lib/inferno/apps/web/controllers/test_runs/destroy.rb
470
471
  - lib/inferno/apps/web/controllers/test_runs/results/index.rb
@@ -479,6 +480,7 @@ files:
479
480
  - lib/inferno/apps/web/controllers/test_sessions/show.rb
480
481
  - lib/inferno/apps/web/controllers/test_suites/check_configuration.rb
481
482
  - lib/inferno/apps/web/controllers/test_suites/index.rb
483
+ - lib/inferno/apps/web/controllers/test_suites/requirements/index.rb
482
484
  - lib/inferno/apps/web/controllers/test_suites/show.rb
483
485
  - lib/inferno/apps/web/index.html.erb
484
486
  - lib/inferno/apps/web/router.rb
@@ -489,6 +491,8 @@ files:
489
491
  - lib/inferno/apps/web/serializers/message.rb
490
492
  - lib/inferno/apps/web/serializers/preset.rb
491
493
  - lib/inferno/apps/web/serializers/request.rb
494
+ - lib/inferno/apps/web/serializers/requirement.rb
495
+ - lib/inferno/apps/web/serializers/requirement_set.rb
492
496
  - lib/inferno/apps/web/serializers/result.rb
493
497
  - lib/inferno/apps/web/serializers/serializer.rb
494
498
  - lib/inferno/apps/web/serializers/session_data.rb
@@ -505,6 +509,7 @@ files:
505
509
  - lib/inferno/config/boot/ig_files.rb
506
510
  - lib/inferno/config/boot/logging.rb
507
511
  - lib/inferno/config/boot/presets.rb
512
+ - lib/inferno/config/boot/requirements.rb
508
513
  - lib/inferno/config/boot/sidekiq.rb
509
514
  - lib/inferno/config/boot/suites.rb
510
515
  - lib/inferno/config/boot/validator.rb
@@ -535,11 +540,14 @@ files:
535
540
  - lib/inferno/dsl/fhir_evaluation/reference_extractor.rb
536
541
  - lib/inferno/dsl/fhir_evaluation/rule.rb
537
542
  - lib/inferno/dsl/fhir_evaluation/rules/all_defined_extensions_have_examples.rb
543
+ - lib/inferno/dsl/fhir_evaluation/rules/all_extensions_used.rb
538
544
  - lib/inferno/dsl/fhir_evaluation/rules/all_must_supports_present.rb
539
545
  - lib/inferno/dsl/fhir_evaluation/rules/all_profiles_have_examples.rb
540
546
  - lib/inferno/dsl/fhir_evaluation/rules/all_references_resolve.rb
541
547
  - lib/inferno/dsl/fhir_evaluation/rules/all_resources_reachable.rb
542
548
  - lib/inferno/dsl/fhir_evaluation/rules/all_search_parameters_have_examples.rb
549
+ - lib/inferno/dsl/fhir_evaluation/rules/differential_content_has_examples.rb
550
+ - lib/inferno/dsl/fhir_evaluation/rules/value_sets_demonstrate.rb
543
551
  - lib/inferno/dsl/fhir_resource_navigation.rb
544
552
  - lib/inferno/dsl/fhir_resource_validation.rb
545
553
  - lib/inferno/dsl/fhir_validation.rb
@@ -556,11 +564,13 @@ files:
556
564
  - lib/inferno/dsl/oauth_credentials.rb
557
565
  - lib/inferno/dsl/primitive_type.rb
558
566
  - lib/inferno/dsl/request_storage.rb
567
+ - lib/inferno/dsl/requirement_set.rb
559
568
  - lib/inferno/dsl/results.rb
560
569
  - lib/inferno/dsl/resume_test_route.rb
561
570
  - lib/inferno/dsl/runnable.rb
562
571
  - lib/inferno/dsl/suite_endpoint.rb
563
572
  - lib/inferno/dsl/suite_option.rb
573
+ - lib/inferno/dsl/suite_requirements.rb
564
574
  - lib/inferno/dsl/tcp_exception_handler.rb
565
575
  - lib/inferno/dsl/value_extractor.rb
566
576
  - lib/inferno/entities.rb
@@ -573,6 +583,7 @@ files:
573
583
  - lib/inferno/entities/message.rb
574
584
  - lib/inferno/entities/preset.rb
575
585
  - lib/inferno/entities/request.rb
586
+ - lib/inferno/entities/requirement.rb
576
587
  - lib/inferno/entities/result.rb
577
588
  - lib/inferno/entities/session_data.rb
578
589
  - lib/inferno/entities/test.rb
@@ -607,6 +618,7 @@ files:
607
618
  - lib/inferno/repositories/presets.rb
608
619
  - lib/inferno/repositories/repository.rb
609
620
  - lib/inferno/repositories/requests.rb
621
+ - lib/inferno/repositories/requirements.rb
610
622
  - lib/inferno/repositories/results.rb
611
623
  - lib/inferno/repositories/session_data.rb
612
624
  - lib/inferno/repositories/tags.rb