inferno_core 1.0.8 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/inferno/dsl/fhir_resource_validation.rb +413 -149
- data/lib/inferno/dsl/fhir_validation.rb +11 -3
- data/lib/inferno/jobs/invoke_validator_session.rb +1 -10
- data/lib/inferno/version.rb +1 -1
- data/spec/spec_helper.rb +1 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 11e324b2bb99be1f0100e0b37829fa92668feab98fe2424f301a73ca70a82542
|
|
4
|
+
data.tar.gz: 074fce8e9975dad0acebad6276c029016ffb219dcedc4e056970f8cadc7e63f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 303453ddfa0ecaedf8f68359f2cc90041372f74736917e3313bcde9c1f9c90edae4b271cbd422f3e875e373e32177a9ab814d12e65fa5214c13a4262b71e5d27
|
|
7
|
+
data.tar.gz: 06eac23eb184c16cce19b5a6dceac53d73f87bd77baea45c8f138a384414cf683aff7ea9a5233cc254a8effdce3144b5b9bcee18468110977a6cbe43be445c93
|
|
@@ -42,7 +42,6 @@ module Inferno
|
|
|
42
42
|
attr_reader :requirements
|
|
43
43
|
attr_accessor :session_id, :name, :test_suite_id
|
|
44
44
|
|
|
45
|
-
# @private
|
|
46
45
|
def initialize(name = nil, test_suite_id = nil, requirements = nil, &)
|
|
47
46
|
@name = name
|
|
48
47
|
@test_suite_id = test_suite_id
|
|
@@ -50,11 +49,6 @@ module Inferno
|
|
|
50
49
|
@requirements = requirements
|
|
51
50
|
end
|
|
52
51
|
|
|
53
|
-
# @private
|
|
54
|
-
def default_validator_url
|
|
55
|
-
ENV.fetch('FHIR_RESOURCE_VALIDATOR_URL')
|
|
56
|
-
end
|
|
57
|
-
|
|
58
52
|
def validator_session_repo
|
|
59
53
|
@validator_session_repo ||= Inferno::Repositories::ValidatorSessions.new
|
|
60
54
|
end
|
|
@@ -64,7 +58,7 @@ module Inferno
|
|
|
64
58
|
# @param validator_url [String]
|
|
65
59
|
def url(validator_url = nil)
|
|
66
60
|
@url = validator_url if validator_url
|
|
67
|
-
@url ||=
|
|
61
|
+
@url ||= ENV.fetch('FHIR_RESOURCE_VALIDATOR_URL')
|
|
68
62
|
@url
|
|
69
63
|
end
|
|
70
64
|
|
|
@@ -124,6 +118,7 @@ module Inferno
|
|
|
124
118
|
alias cli_context validation_context
|
|
125
119
|
|
|
126
120
|
# @private
|
|
121
|
+
# Used internally by perform_additional_validation
|
|
127
122
|
def additional_validations
|
|
128
123
|
@additional_validations ||= []
|
|
129
124
|
end
|
|
@@ -151,13 +146,6 @@ module Inferno
|
|
|
151
146
|
additional_validations << block
|
|
152
147
|
end
|
|
153
148
|
|
|
154
|
-
# @private
|
|
155
|
-
def additional_validation_messages(resource, profile_url)
|
|
156
|
-
additional_validations
|
|
157
|
-
.flat_map { |step| step.call(resource, profile_url) }
|
|
158
|
-
.select { |message| message.is_a? Hash }
|
|
159
|
-
end
|
|
160
|
-
|
|
161
149
|
# Filter out unwanted validation messages. Any messages for which the
|
|
162
150
|
# block evalutates to a truthy value will be excluded.
|
|
163
151
|
#
|
|
@@ -171,99 +159,380 @@ module Inferno
|
|
|
171
159
|
@exclude_message
|
|
172
160
|
end
|
|
173
161
|
|
|
162
|
+
# Validate a FHIR resource and determine if it's valid.
|
|
163
|
+
# Adds validation messages to the runnable if add_messages_to_runnable is true.
|
|
164
|
+
#
|
|
174
165
|
# @see Inferno::DSL::FHIRResourceValidation#resource_is_valid?
|
|
175
|
-
|
|
166
|
+
# @param resource [FHIR::Model] the resource to validate
|
|
167
|
+
# @param profile_url [String] the profile URL to validate against
|
|
168
|
+
# @param runnable [Object] the runnable context (test/group/suite)
|
|
169
|
+
# @param add_messages_to_runnable [Boolean] whether to add messages to the runnable
|
|
170
|
+
# @param validator_response_details [Array, nil] if not nil, the service will populate this array with
|
|
171
|
+
# the detailed response message from the validator service. Can be used by test kits to perform custom
|
|
172
|
+
# handling of error messages.
|
|
173
|
+
# @return [Boolean] true if the resource is valid
|
|
174
|
+
def resource_is_valid?(resource, profile_url, runnable, add_messages_to_runnable: true,
|
|
175
|
+
validator_response_details: nil)
|
|
176
176
|
profile_url ||= FHIR::Definitions.resource_definition(resource.resourceType).url
|
|
177
177
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
rescue StandardError => e
|
|
181
|
-
runnable.add_message('error', e.message)
|
|
182
|
-
Application[:logger].error(e.message)
|
|
178
|
+
# 1. Get raw content from validator
|
|
179
|
+
response = get_raw_validator_content(resource, profile_url, runnable)
|
|
183
180
|
|
|
184
|
-
|
|
185
|
-
|
|
181
|
+
# 2. Convert to validation issues
|
|
182
|
+
issues = get_issues_from_validator_response(response, resource)
|
|
186
183
|
|
|
187
|
-
|
|
184
|
+
# 3. Add additional validation messages
|
|
185
|
+
issues = join_additional_validation_messages(issues, resource, profile_url)
|
|
188
186
|
|
|
189
|
-
|
|
187
|
+
# 4. Mark resources as filtered
|
|
188
|
+
mark_issues_for_filtering(issues)
|
|
190
189
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
190
|
+
# 5. Add error messages to runnable
|
|
191
|
+
filtered_issues = issues.reject(&:filtered)
|
|
192
|
+
add_validation_messages_to_runnable(runnable, filtered_issues) if add_messages_to_runnable
|
|
193
|
+
validator_response_details&.concat(issues)
|
|
194
|
+
|
|
195
|
+
# 6. Return validity
|
|
196
|
+
filtered_issues.none? { |issue| issue.severity == 'error' }
|
|
197
|
+
rescue Inferno::Exceptions::ErrorInValidatorException
|
|
198
|
+
raise
|
|
199
|
+
rescue StandardError => e
|
|
200
|
+
runnable.add_message('error', e.message)
|
|
201
|
+
raise Inferno::Exceptions::ErrorInValidatorException,
|
|
202
|
+
'Error occurred in the validator. Review Messages tab or validator service logs for more information.'
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# @private
|
|
206
|
+
# Gets raw content from validator including error handling
|
|
207
|
+
# @param resource [FHIR::Model] the resource to validate
|
|
208
|
+
# @param profile_url [String] the profile URL to validate against
|
|
209
|
+
# @param runnable [Object] the runnable context
|
|
210
|
+
# @return [Faraday::Response] the HTTP response from the validator
|
|
211
|
+
def get_raw_validator_content(resource, profile_url, runnable)
|
|
212
|
+
response = call_validator(resource, profile_url)
|
|
195
213
|
|
|
196
214
|
unless response.status == 200
|
|
197
215
|
raise Inferno::Exceptions::ErrorInValidatorException,
|
|
198
216
|
'Error occurred in the validator. Review Messages tab or validator service logs for more information.'
|
|
199
217
|
end
|
|
200
218
|
|
|
201
|
-
|
|
202
|
-
.none? { |message_hash| message_hash[:type] == 'error' }
|
|
203
|
-
rescue Inferno::Exceptions::ErrorInValidatorException
|
|
204
|
-
raise
|
|
219
|
+
response
|
|
205
220
|
rescue StandardError => e
|
|
206
221
|
runnable.add_message('error', e.message)
|
|
207
|
-
|
|
208
|
-
|
|
222
|
+
Application[:logger].error(e.message)
|
|
223
|
+
raise Inferno::Exceptions::ErrorInValidatorException, validator_error_message(e)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# @private
|
|
227
|
+
# Adds validation messages to the runnable
|
|
228
|
+
def add_validation_messages_to_runnable(runnable, filtered_issues)
|
|
229
|
+
filtered_issues.each do |issue|
|
|
230
|
+
runnable.add_message(issue.severity, issue.message)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Warm up the validator session by sending a test validation request.
|
|
235
|
+
# This initializes the validator session and persists it for future use.
|
|
236
|
+
#
|
|
237
|
+
# @param resource [FHIR::Model] the resource to validate
|
|
238
|
+
# @param profile_url [String] the profile URL to validate against
|
|
239
|
+
def warm_up(resource, profile_url)
|
|
240
|
+
response_body = validate(resource, profile_url)
|
|
241
|
+
res = JSON.parse(response_body)
|
|
242
|
+
session_id = res['sessionId']
|
|
243
|
+
validator_session_repo.save(test_suite_id:, validator_session_id: session_id,
|
|
244
|
+
validator_name: name.to_s, suite_options: requirements)
|
|
245
|
+
self.session_id = session_id
|
|
246
|
+
rescue JSON::ParserError
|
|
247
|
+
Application[:logger]
|
|
248
|
+
.error("Validator warm_up - error unexpected response format from validator: #{response_body}")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# @private
|
|
252
|
+
# Converts raw validator response into a list of ValidatorIssue objects.
|
|
253
|
+
# Recursively processes slice information.
|
|
254
|
+
#
|
|
255
|
+
# @param response [Faraday::Response] the HTTP response from the validator
|
|
256
|
+
# @param resource [FHIR::Model] the resource being validated
|
|
257
|
+
# @return [Array<ValidatorIssue>] list of validator issues
|
|
258
|
+
def get_issues_from_validator_response(response, resource)
|
|
259
|
+
response_body = remove_invalid_characters(response.body)
|
|
260
|
+
response_hash = JSON.parse(response_body)
|
|
261
|
+
|
|
262
|
+
if response_hash['sessionId'].present? && response_hash['sessionId'] != @session_id
|
|
263
|
+
validator_session_repo.save(test_suite_id:, validator_session_id: response_hash['sessionId'],
|
|
264
|
+
validator_name: name.to_s, suite_options: requirements)
|
|
265
|
+
@session_id = response_hash['sessionId']
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
raw_issues = response_hash.dig('outcomes', 0, 'issues') || []
|
|
269
|
+
|
|
270
|
+
raw_issues.map do |raw_issue|
|
|
271
|
+
convert_raw_issue_to_validator_issue(raw_issue, resource)
|
|
272
|
+
end
|
|
209
273
|
end
|
|
210
274
|
|
|
211
275
|
# @private
|
|
276
|
+
# Converts a single raw issue hash to a ValidatorIssue object.
|
|
277
|
+
# Recursively processes sliceInfo if present.
|
|
278
|
+
#
|
|
279
|
+
# @param raw_issue [Hash] the raw issue from validator response
|
|
280
|
+
# @param resource [FHIR::Model] the resource being validated
|
|
281
|
+
# @return [ValidatorIssue] the converted validator issue
|
|
282
|
+
def convert_raw_issue_to_validator_issue(raw_issue, resource)
|
|
283
|
+
# Recursively process sliceInfo
|
|
284
|
+
slice_info = []
|
|
285
|
+
if raw_issue['sliceInfo']&.any?
|
|
286
|
+
slice_info = raw_issue['sliceInfo'].map do |slice_issue|
|
|
287
|
+
convert_raw_issue_to_validator_issue(slice_issue, resource)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
ValidatorIssue.new(
|
|
292
|
+
raw_issue: raw_issue,
|
|
293
|
+
resource: resource,
|
|
294
|
+
slice_info: slice_info,
|
|
295
|
+
filtered: false
|
|
296
|
+
)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# @private
|
|
300
|
+
def call_validator(resource, profile_url)
|
|
301
|
+
request_body = wrap_resource_for_hl7_wrapper(resource, profile_url)
|
|
302
|
+
Faraday.new(
|
|
303
|
+
url,
|
|
304
|
+
request: { timeout: 600 }
|
|
305
|
+
).post('validate', request_body, content_type: 'application/json')
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# @private
|
|
309
|
+
# Post a resource to the validation service for validating.
|
|
310
|
+
# Returns the raw validator response body.
|
|
311
|
+
#
|
|
312
|
+
# @param resource [FHIR::Model]
|
|
313
|
+
# @param profile_url [String]
|
|
314
|
+
# @return [String] the body of the validation response
|
|
315
|
+
def validate(resource, profile_url)
|
|
316
|
+
call_validator(resource, profile_url).body
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Add a specific error message for specific network problems to help the user
|
|
320
|
+
#
|
|
321
|
+
# @private
|
|
322
|
+
# @param error [Exception] An error exception that happened during evaluator connection
|
|
323
|
+
# @return [String] A readable error message describing the specific network problem
|
|
324
|
+
def validator_error_message(error)
|
|
325
|
+
case error
|
|
326
|
+
when Faraday::ConnectionFailed
|
|
327
|
+
"Connection failed to validator at #{url}."
|
|
328
|
+
when Faraday::TimeoutError
|
|
329
|
+
"Timeout while connecting to validator at #{url}."
|
|
330
|
+
when Faraday::SSLError
|
|
331
|
+
"SSL error connecting to validator at #{url}."
|
|
332
|
+
when Faraday::ClientError # these are 400s
|
|
333
|
+
"Client error (4xx) connecting to validator at #{url}."
|
|
334
|
+
when Faraday::ServerError # these are 500s
|
|
335
|
+
"Server error (5xx) from validator at #{url}."
|
|
336
|
+
else
|
|
337
|
+
"Unable to connect to validator at #{url}."
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# @private
|
|
342
|
+
# Removes invalid characters from a string to prepare for JSON parsing
|
|
343
|
+
#
|
|
344
|
+
# @param string [String] the string to clean
|
|
345
|
+
# @return [String] the cleaned string
|
|
346
|
+
def remove_invalid_characters(string)
|
|
347
|
+
string.gsub(/[^[:print:]\r\n]+/, '')
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# @private
|
|
351
|
+
# Joins additional validation messages to the issues list
|
|
352
|
+
#
|
|
353
|
+
# @param issues [Array<ValidatorIssue>] the list of validator issues
|
|
354
|
+
# @param resource [FHIR::Model] the resource being validated
|
|
355
|
+
# @param profile_url [String] the profile URL being validated against
|
|
356
|
+
# @return [Array<ValidatorIssue>] the complete list of issues including additional messages
|
|
357
|
+
def join_additional_validation_messages(issues, resource, profile_url)
|
|
358
|
+
additional_issues = additional_validation_messages(resource, profile_url)
|
|
359
|
+
issues + additional_issues
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# @private
|
|
363
|
+
# Marks validation issues for filtering by setting the filtered flag on issues that should be excluded.
|
|
364
|
+
# Recursively marks issues in slice_info.
|
|
365
|
+
#
|
|
366
|
+
# @param issues [Array<ValidatorIssue>] the list of validator issues
|
|
367
|
+
def mark_issues_for_filtering(issues)
|
|
368
|
+
# Recursively mark all issues for filtering
|
|
369
|
+
filter_individual_messages(issues)
|
|
370
|
+
|
|
371
|
+
# Perform conditional filtering based on special cases
|
|
372
|
+
apply_relationship_filters(issues)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# @private
|
|
376
|
+
# Gets additional validation messages from custom validation blocks.
|
|
377
|
+
# Converts the message hashes to ValidatorIssue objects.
|
|
378
|
+
#
|
|
379
|
+
# @param resource [FHIR::Model] the resource being validated
|
|
380
|
+
# @param profile_url [String] the profile URL being validated against
|
|
381
|
+
# @return [Array<ValidatorIssue>] list of additional validator issues
|
|
382
|
+
def additional_validation_messages(resource, profile_url)
|
|
383
|
+
additional_validations
|
|
384
|
+
.flat_map { |step| step.call(resource, profile_url) }
|
|
385
|
+
.select { |message| message.is_a? Hash }
|
|
386
|
+
.map do |message_hash|
|
|
387
|
+
# Create a synthetic raw_issue for additional validation messages
|
|
388
|
+
synthetic_raw_issue = {
|
|
389
|
+
'level' => message_hash[:type].upcase,
|
|
390
|
+
'location' => 'additional_validation',
|
|
391
|
+
'message' => message_hash[:message]
|
|
392
|
+
}
|
|
393
|
+
ValidatorIssue.new(
|
|
394
|
+
raw_issue: synthetic_raw_issue,
|
|
395
|
+
resource: resource,
|
|
396
|
+
slice_info: [],
|
|
397
|
+
filtered: false
|
|
398
|
+
)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# @private
|
|
403
|
+
# Recursively filters validation issues by setting the filtered flag.
|
|
404
|
+
# Applies filtering to both the issue itself and all nested slice_info.
|
|
405
|
+
#
|
|
406
|
+
# @param issues [Array<ValidatorIssue>] the issues to filter
|
|
407
|
+
def filter_individual_messages(issues)
|
|
408
|
+
issues.each do |issue|
|
|
409
|
+
# Create a mock message entity to check filtering rules
|
|
410
|
+
mock_message = Entities::Message.new(type: issue.severity, message: issue.message)
|
|
411
|
+
issue.filtered = should_filter_message?(mock_message)
|
|
412
|
+
|
|
413
|
+
# Recursively filter slice_info
|
|
414
|
+
filter_individual_messages(issue.slice_info) if issue.slice_info.any?
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# @private
|
|
419
|
+
# Determines if a message should be filtered based on exclusion rules.
|
|
420
|
+
# Applies both the unresolved URL filter and any custom exclude_message filter.
|
|
421
|
+
#
|
|
422
|
+
# @param message [Inferno::Entities::Message] the message to check
|
|
423
|
+
# @return [Boolean] true if the message should be filtered out
|
|
424
|
+
def should_filter_message?(message)
|
|
425
|
+
should_filter = exclude_unresolved_url_message.call(message) ||
|
|
426
|
+
exclude_message&.call(message)
|
|
427
|
+
should_filter || false
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# @private
|
|
431
|
+
# Filter for excluding unresolved URL validation messages
|
|
432
|
+
#
|
|
433
|
+
# @return [Proc] a proc that checks if a message is an unresolved URL message
|
|
212
434
|
def exclude_unresolved_url_message
|
|
213
|
-
proc do |message|
|
|
435
|
+
@exclude_unresolved_url_message ||= proc do |message|
|
|
214
436
|
message.message.match?(/\A\S+: [^:]+: URL value '.*' does not resolve/) ||
|
|
215
437
|
message.message.match?(/\A\S+: [^:]+: No definition could be found for URL value '.*'/)
|
|
216
438
|
end
|
|
217
439
|
end
|
|
218
440
|
|
|
219
441
|
# @private
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
442
|
+
# Performs filtering based on relationships between issues.
|
|
443
|
+
# Processes sub-issues of each issue before processing the top-level issues.
|
|
444
|
+
#
|
|
445
|
+
# @param issues [Array<ValidatorIssue>] the list of validator issues
|
|
446
|
+
def apply_relationship_filters(issues)
|
|
447
|
+
apply_relationship_filters_to_children(issues)
|
|
448
|
+
|
|
449
|
+
issues.each_with_index do |issue, index|
|
|
450
|
+
next if issue.filtered # Skip if already filtered
|
|
451
|
+
|
|
452
|
+
# Apply conditional filters.
|
|
453
|
+
# As more are needed, split with a "next if issue.filtered" pattern and add the new filter.
|
|
454
|
+
filter_contained_resource(issues, issue, index)
|
|
455
|
+
end
|
|
223
456
|
end
|
|
224
457
|
|
|
225
458
|
# @private
|
|
226
|
-
|
|
227
|
-
|
|
459
|
+
# Performs filtering based on relationships between issues on the sub-issues
|
|
460
|
+
# of a list of issues.
|
|
461
|
+
#
|
|
462
|
+
# @param issues [Array<ValidatorIssue>] the list of validator issues
|
|
463
|
+
def apply_relationship_filters_to_children(issues)
|
|
464
|
+
issues.each do |issue|
|
|
465
|
+
next if issue.filtered # Skip if already filtered
|
|
228
466
|
|
|
229
|
-
|
|
467
|
+
# Recursively process nested slice_info first (depth-first)
|
|
468
|
+
apply_relationship_filters(issue.slice_info) if issue.slice_info.any?
|
|
469
|
+
end
|
|
470
|
+
end
|
|
230
471
|
|
|
231
|
-
|
|
472
|
+
# @private
|
|
473
|
+
# Filters Reference_REF_CantMatchChoice errors for contained resources.
|
|
474
|
+
# If a resource matches at least one profile (all slices filtered), marks the base error as filtered.
|
|
475
|
+
#
|
|
476
|
+
# @param issues [Array<ValidatorIssue>] the complete list of issues
|
|
477
|
+
# @param base_issue [ValidatorIssue] the issue to potentially filter
|
|
478
|
+
# @param base_index [Integer] the index of the base issue in the issues array
|
|
479
|
+
def filter_contained_resource(issues, base_issue, base_index)
|
|
480
|
+
return unless contained_resource_profile_issue?(base_issue)
|
|
232
481
|
|
|
233
|
-
|
|
482
|
+
base_location = base_issue.location
|
|
483
|
+
profile_detail_issues = find_following_profile_details_issues(issues, base_index, base_location)
|
|
484
|
+
|
|
485
|
+
return if profile_detail_issues.empty?
|
|
486
|
+
return unless at_least_one_profile_without_errors?(profile_detail_issues)
|
|
487
|
+
|
|
488
|
+
base_issue.filtered = true
|
|
489
|
+
# Also filter all the Details messages
|
|
490
|
+
profile_detail_issues.each { |details_issue| details_issue.filtered = true }
|
|
234
491
|
end
|
|
235
492
|
|
|
236
493
|
# @private
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
494
|
+
# Checks if a base issue should be processed for contained resource filtering
|
|
495
|
+
def contained_resource_profile_issue?(base_issue)
|
|
496
|
+
return false if base_issue.filtered # Skip if already filtered
|
|
497
|
+
|
|
498
|
+
message_id = base_issue.raw_issue['messageId']
|
|
499
|
+
return false unless message_id == 'Reference_REF_CantMatchChoice'
|
|
500
|
+
return false unless base_issue.severity == 'error' || base_issue.severity == 'warning'
|
|
501
|
+
|
|
502
|
+
true
|
|
242
503
|
end
|
|
243
504
|
|
|
244
505
|
# @private
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
'
|
|
249
|
-
|
|
250
|
-
'info'
|
|
251
|
-
else
|
|
252
|
-
'error'
|
|
506
|
+
# Checks if any profile is valid (all error-level slices are filtered)
|
|
507
|
+
def at_least_one_profile_without_errors?(details_issues)
|
|
508
|
+
details_issues.any? do |details_issue|
|
|
509
|
+
error_level_slices = details_issue.slice_info.select { |s| s.severity == 'error' }
|
|
510
|
+
error_level_slices.all?(&:filtered)
|
|
253
511
|
end
|
|
254
512
|
end
|
|
255
513
|
|
|
256
514
|
# @private
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
515
|
+
# Finds consecutive Details messages following a base issue at the same location.
|
|
516
|
+
#
|
|
517
|
+
# @param issues [Array<ValidatorIssue>] the complete list of issues
|
|
518
|
+
# @param start_index [Integer] the index to start searching from
|
|
519
|
+
# @param base_location [String] the location to match
|
|
520
|
+
# @return [Array<ValidatorIssue>] the list of Details issues
|
|
521
|
+
def find_following_profile_details_issues(issues, start_index, base_location)
|
|
522
|
+
details_issues = []
|
|
523
|
+
index = start_index + 1
|
|
524
|
+
|
|
525
|
+
while index < issues.length
|
|
526
|
+
issue = issues[index]
|
|
527
|
+
|
|
528
|
+
# Check if this is a Details message for the same location
|
|
529
|
+
break unless issue.message.include?('Details for #') && issue.location == base_location
|
|
530
|
+
|
|
531
|
+
details_issues << issue
|
|
532
|
+
index += 1
|
|
533
|
+
end
|
|
265
534
|
|
|
266
|
-
|
|
535
|
+
details_issues
|
|
267
536
|
end
|
|
268
537
|
|
|
269
538
|
# @private
|
|
@@ -294,87 +563,8 @@ module Inferno
|
|
|
294
563
|
}
|
|
295
564
|
wrapped_resource.to_json
|
|
296
565
|
end
|
|
297
|
-
|
|
298
|
-
# Post a resource to the validation service for validating.
|
|
299
|
-
#
|
|
300
|
-
# @param resource [FHIR::Model]
|
|
301
|
-
# @param profile_url [String]
|
|
302
|
-
# @return [String] the body of the validation response
|
|
303
|
-
def validate(resource, profile_url)
|
|
304
|
-
call_validator(resource, profile_url).body
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
# @private
|
|
308
|
-
def call_validator(resource, profile_url)
|
|
309
|
-
request_body = wrap_resource_for_hl7_wrapper(resource, profile_url)
|
|
310
|
-
Faraday.new(
|
|
311
|
-
url,
|
|
312
|
-
request: { timeout: 600 }
|
|
313
|
-
).post('validate', request_body, content_type: 'application/json')
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
# @private
|
|
317
|
-
def operation_outcome_from_hl7_wrapped_response(response_hash)
|
|
318
|
-
# This is a workaround for some test kits which for legacy reasons
|
|
319
|
-
# call this method directly with a String instead of a Hash.
|
|
320
|
-
# See FI-3178.
|
|
321
|
-
response_hash = JSON.parse(remove_invalid_characters(response_hash)) if response_hash.is_a? String
|
|
322
|
-
|
|
323
|
-
if response_hash['sessionId'] && response_hash['sessionId'] != @session_id
|
|
324
|
-
validator_session_repo.save(test_suite_id:, validator_session_id: response_hash['sessionId'],
|
|
325
|
-
validator_name: name.to_s, suite_options: requirements)
|
|
326
|
-
@session_id = response_hash['sessionId']
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
# assume for now that one resource -> one request
|
|
330
|
-
issues = (response_hash.dig('outcomes', 0, 'issues') || []).map do |i|
|
|
331
|
-
{ severity: i['level'].downcase, expression: i['location'], details: { text: i['message'] } }
|
|
332
|
-
end
|
|
333
|
-
# this is circuitous, ideally we would map this response directly to message_hashes
|
|
334
|
-
FHIR::OperationOutcome.new(issue: issues)
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
# @private
|
|
338
|
-
def remove_invalid_characters(string)
|
|
339
|
-
string.gsub(/[^[:print:]\r\n]+/, '')
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
# @private
|
|
343
|
-
def operation_outcome_from_validator_response(response, runnable)
|
|
344
|
-
sanitized_body = remove_invalid_characters(response.body)
|
|
345
|
-
|
|
346
|
-
operation_outcome_from_hl7_wrapped_response(JSON.parse(sanitized_body))
|
|
347
|
-
rescue JSON::ParserError
|
|
348
|
-
runnable.add_message('error', "Validator Response: HTTP #{response.status}\n#{sanitized_body}")
|
|
349
|
-
raise Inferno::Exceptions::ErrorInValidatorException,
|
|
350
|
-
'Validator response was an unexpected format. ' \
|
|
351
|
-
'Review Messages tab or validator service logs for more information.'
|
|
352
|
-
end
|
|
353
|
-
|
|
354
|
-
# Add a specific error message for specific network problems to help the user
|
|
355
|
-
#
|
|
356
|
-
# @private
|
|
357
|
-
# @param error [Exception] An error exception that happened during evaluator connection
|
|
358
|
-
# @return [String] A readable error message describing the specific network problem
|
|
359
|
-
def validator_error_message(error)
|
|
360
|
-
case error
|
|
361
|
-
when Faraday::ConnectionFailed
|
|
362
|
-
"Connection failed to validator at #{url}."
|
|
363
|
-
when Faraday::TimeoutError
|
|
364
|
-
"Timeout while connecting to validator at #{url}."
|
|
365
|
-
when Faraday::SSLError
|
|
366
|
-
"SSL error connecting to validator at #{url}."
|
|
367
|
-
when Faraday::ClientError # these are 400s
|
|
368
|
-
"Client error (4xx) connecting to validator at #{url}."
|
|
369
|
-
when Faraday::ServerError # these are 500s
|
|
370
|
-
"Server error (5xx) from validator at #{url}."
|
|
371
|
-
else
|
|
372
|
-
"Unable to connect to validator at #{url}."
|
|
373
|
-
end
|
|
374
|
-
end
|
|
375
566
|
end
|
|
376
567
|
|
|
377
|
-
# @private
|
|
378
568
|
class ValidationContext
|
|
379
569
|
attr_reader :definition
|
|
380
570
|
|
|
@@ -385,13 +575,11 @@ module Inferno
|
|
|
385
575
|
disableDefaultResourceFetcher: true
|
|
386
576
|
}.freeze
|
|
387
577
|
|
|
388
|
-
# @private
|
|
389
578
|
def initialize(definition, &)
|
|
390
579
|
@definition = VALIDATIONCONTEXT_DEFAULTS.merge(definition.deep_symbolize_keys)
|
|
391
580
|
instance_eval(&) if block_given?
|
|
392
581
|
end
|
|
393
582
|
|
|
394
|
-
# @private
|
|
395
583
|
def method_missing(method_name, *args)
|
|
396
584
|
# Interpret any other method as setting a field on validationContext.
|
|
397
585
|
# Follow the same format as `Validator.url` here:
|
|
@@ -402,14 +590,83 @@ module Inferno
|
|
|
402
590
|
definition[method_name]
|
|
403
591
|
end
|
|
404
592
|
|
|
405
|
-
# @private
|
|
406
593
|
def respond_to_missing?(_method_name, _include_private = false)
|
|
407
594
|
true
|
|
408
595
|
end
|
|
409
596
|
end
|
|
410
597
|
|
|
598
|
+
# ValidatorIssue represents a single validation issue returned from the FHIR validator
|
|
599
|
+
class ValidatorIssue
|
|
600
|
+
attr_accessor :filtered, :raw_issue, :slice_info
|
|
601
|
+
attr_reader :resource
|
|
602
|
+
|
|
603
|
+
# Creates a new ValidatorIssue
|
|
604
|
+
# @param raw_issue [Hash] the raw issue hash from the validator response
|
|
605
|
+
# @param resource [FHIR::Model] the resource being validated
|
|
606
|
+
# @param slice_info [Array<ValidatorIssue>] nested slice information as ValidatorIssue objects
|
|
607
|
+
# @param filtered [Boolean] whether this issue has been filtered out
|
|
608
|
+
def initialize(raw_issue:, resource:, slice_info: [], filtered: false)
|
|
609
|
+
@raw_issue = raw_issue
|
|
610
|
+
@resource = resource
|
|
611
|
+
@slice_info = slice_info
|
|
612
|
+
@filtered = filtered
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Lazily calculated formatted message
|
|
616
|
+
# @return [String] the formatted message for the issue
|
|
617
|
+
def message
|
|
618
|
+
@message ||= format_message
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# Lazily calculated severity level
|
|
622
|
+
# @return [String] 'error', 'warning', or 'info'
|
|
623
|
+
def severity
|
|
624
|
+
@severity ||= calculate_severity
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Extracted location from the issue
|
|
628
|
+
# @return [String] the location string
|
|
629
|
+
def location
|
|
630
|
+
@location ||= extract_location
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
private
|
|
634
|
+
|
|
635
|
+
# Formats the issue message with location prefix
|
|
636
|
+
# @return [String] the formatted message
|
|
637
|
+
def format_message
|
|
638
|
+
location_value = location
|
|
639
|
+
details_text = raw_issue['message']
|
|
640
|
+
|
|
641
|
+
# Don't add prefix for additional validation messages
|
|
642
|
+
return details_text if location_value == 'additional_validation'
|
|
643
|
+
|
|
644
|
+
location_prefix = resource.id ? "#{resource.resourceType}/#{resource.id}" : resource.resourceType
|
|
645
|
+
"#{location_prefix}: #{location_value}: #{details_text}"
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
# Converts the validator's severity level to our standard format
|
|
649
|
+
# @return [String] 'error', 'warning', or 'info'
|
|
650
|
+
def calculate_severity
|
|
651
|
+
level = raw_issue['level']&.upcase
|
|
652
|
+
case level
|
|
653
|
+
when 'ERROR', 'FATAL'
|
|
654
|
+
'error'
|
|
655
|
+
when 'WARNING'
|
|
656
|
+
'warning'
|
|
657
|
+
else
|
|
658
|
+
'info'
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# Extracts the location from the raw issue
|
|
663
|
+
# @return [String] the location string
|
|
664
|
+
def extract_location
|
|
665
|
+
raw_issue['location'] || 'unknown'
|
|
666
|
+
end
|
|
667
|
+
end
|
|
668
|
+
|
|
411
669
|
module ClassMethods
|
|
412
|
-
# @private
|
|
413
670
|
def fhir_validators
|
|
414
671
|
@fhir_validators ||= {}
|
|
415
672
|
end
|
|
@@ -443,7 +700,14 @@ module Inferno
|
|
|
443
700
|
fhir_validators[name] = current_validators
|
|
444
701
|
end
|
|
445
702
|
|
|
446
|
-
#
|
|
703
|
+
# Find a particular profile StructureDefinition and the IG it belongs to.
|
|
704
|
+
# Looks through validators to find the profile by looking through their defined igs.
|
|
705
|
+
#
|
|
706
|
+
# Note: Requires find_validator method which is defined elsewhere in the codebase
|
|
707
|
+
#
|
|
708
|
+
# @param profile_url [String] the profile URL to find
|
|
709
|
+
# @param validator_name [Symbol] the name of the validator to search
|
|
710
|
+
# @return [Array] the IG and profile
|
|
447
711
|
def find_ig_and_profile(profile_url, validator_name)
|
|
448
712
|
validator = find_validator(validator_name)
|
|
449
713
|
if validator.is_a? Inferno::DSL::FHIRResourceValidation::Validator
|
|
@@ -32,12 +32,17 @@ module Inferno
|
|
|
32
32
|
# @param profile_url [String]
|
|
33
33
|
# @param validator [Symbol] the name of the validator to use
|
|
34
34
|
# @param add_messages_to_runnable [Boolean] whether to add validation messages to runnable or not
|
|
35
|
+
# @param validator_response_details [Array, nil] if not nil, the service will populate this array with
|
|
36
|
+
# the detailed response message from the validator service
|
|
35
37
|
# @return [Boolean] whether the resource is valid
|
|
36
38
|
def resource_is_valid?(
|
|
37
39
|
resource: self.resource, profile_url: nil,
|
|
38
|
-
validator: :default, add_messages_to_runnable: true
|
|
40
|
+
validator: :default, add_messages_to_runnable: true,
|
|
41
|
+
validator_response_details: nil
|
|
39
42
|
)
|
|
40
|
-
find_validator(validator).resource_is_valid?(resource, profile_url, self,
|
|
43
|
+
find_validator(validator).resource_is_valid?(resource, profile_url, self,
|
|
44
|
+
add_messages_to_runnable:,
|
|
45
|
+
validator_response_details:)
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
# Find a particular validator. Looks through a runnable's parents up to
|
|
@@ -118,7 +123,9 @@ module Inferno
|
|
|
118
123
|
end
|
|
119
124
|
|
|
120
125
|
# @see Inferno::DSL::FHIRValidation#resource_is_valid?
|
|
121
|
-
|
|
126
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Lint/UnusedMethodArgument
|
|
127
|
+
def resource_is_valid?(resource, profile_url, runnable, add_messages_to_runnable: true,
|
|
128
|
+
validator_response_details: nil)
|
|
122
129
|
profile_url ||= FHIR::Definitions.resource_definition(resource.resourceType).url
|
|
123
130
|
|
|
124
131
|
begin
|
|
@@ -152,6 +159,7 @@ module Inferno
|
|
|
152
159
|
raise Inferno::Exceptions::ErrorInValidatorException,
|
|
153
160
|
'Error occurred in the validator. Review Messages tab or validator service logs for more information.'
|
|
154
161
|
end
|
|
162
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Lint/UnusedMethodArgument
|
|
155
163
|
|
|
156
164
|
# @private
|
|
157
165
|
def exclude_unresolved_url_message
|
|
@@ -7,16 +7,7 @@ module Inferno
|
|
|
7
7
|
def perform(suite_id, validator_name, validator_index)
|
|
8
8
|
suite = Inferno::Repositories::TestSuites.new.find suite_id
|
|
9
9
|
validator = suite.fhir_validators[validator_name.to_sym][validator_index]
|
|
10
|
-
|
|
11
|
-
res = JSON.parse(response_body)
|
|
12
|
-
session_id = res['sessionId']
|
|
13
|
-
session_repo = Inferno::Repositories::ValidatorSessions.new
|
|
14
|
-
session_repo.save(test_suite_id: suite_id, validator_session_id: session_id,
|
|
15
|
-
validator_name:, suite_options: validator.requirements)
|
|
16
|
-
validator.session_id = session_id
|
|
17
|
-
rescue JSON::ParserError
|
|
18
|
-
Inferno::Application['logger']
|
|
19
|
-
.error("InvokeValidatorSession - error unexpected response format from validator: #{response_body}")
|
|
10
|
+
validator.warm_up(FHIR::Patient.new, 'http://hl7.org/fhir/StructureDefinition/Patient')
|
|
20
11
|
end
|
|
21
12
|
end
|
|
22
13
|
end
|
data/lib/inferno/version.rb
CHANGED
data/spec/spec_helper.rb
CHANGED
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: 1.0
|
|
4
|
+
version: 1.1.0
|
|
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: 2026-
|
|
13
|
+
date: 2026-03-05 00:00:00.000000000 Z
|
|
14
14
|
dependencies:
|
|
15
15
|
- !ruby/object:Gem::Dependency
|
|
16
16
|
name: activesupport
|