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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c17ba07e32fab23c4a830a0b6fd22f03ce711fdb89c2a555916355cd1e68e03
4
- data.tar.gz: 4b20e2c2e7d181893f9282ed8954a053beef0a3ee53ce198668b708c024c9aff
3
+ metadata.gz: 11e324b2bb99be1f0100e0b37829fa92668feab98fe2424f301a73ca70a82542
4
+ data.tar.gz: 074fce8e9975dad0acebad6276c029016ffb219dcedc4e056970f8cadc7e63f6
5
5
  SHA512:
6
- metadata.gz: 264803ea3c53af5df0157710f0091b994acebabdb6751e8f442707de01fc57b0f373bb2a5c021f0eb43ba47f1b293c11d534b482787284d352cf6caf09437b47
7
- data.tar.gz: a899635f3a8d68ea3326ed344ce8ae7e8e09ad3f484dce2cba491b7678d849346ed4cb34ef4f0f7bab26f80d4332cd102d8ce896af5403f1b367606f9e2311dc
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 ||= default_validator_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
- def resource_is_valid?(resource, profile_url, runnable, add_messages_to_runnable: true) # rubocop:disable Metrics/CyclomaticComplexity
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
- begin
179
- response = call_validator(resource, profile_url)
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
- raise Inferno::Exceptions::ErrorInValidatorException, validator_error_message(e)
185
- end
181
+ # 2. Convert to validation issues
182
+ issues = get_issues_from_validator_response(response, resource)
186
183
 
187
- outcome = operation_outcome_from_validator_response(response, runnable)
184
+ # 3. Add additional validation messages
185
+ issues = join_additional_validation_messages(issues, resource, profile_url)
188
186
 
189
- message_hashes = message_hashes_from_outcome(outcome, resource, profile_url)
187
+ # 4. Mark resources as filtered
188
+ mark_issues_for_filtering(issues)
190
189
 
191
- if add_messages_to_runnable
192
- message_hashes
193
- .each { |message_hash| runnable.add_message(message_hash[:type], message_hash[:message]) }
194
- end
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
- message_hashes
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
- raise Inferno::Exceptions::ErrorInValidatorException,
208
- 'Error occurred in the validator. Review Messages tab or validator service logs for more information.'
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
- def filter_messages(message_hashes)
221
- message_hashes.reject! { |message| exclude_unresolved_url_message.call(Entities::Message.new(message)) }
222
- message_hashes.reject! { |message| exclude_message.call(Entities::Message.new(message)) } if exclude_message
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
- def message_hashes_from_outcome(outcome, resource, profile_url)
227
- message_hashes = outcome.issue&.map { |issue| message_hash_from_issue(issue, resource) } || []
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
- message_hashes.concat(additional_validation_messages(resource, profile_url))
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
- filter_messages(message_hashes)
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
- message_hashes
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
- def message_hash_from_issue(issue, resource)
238
- {
239
- type: issue_severity(issue),
240
- message: issue_message(issue, resource)
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
- def issue_severity(issue)
246
- case issue.severity
247
- when 'warning'
248
- 'warning'
249
- when 'information'
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
- def issue_message(issue, resource)
258
- location = if issue.respond_to?(:expression)
259
- issue.expression&.join(', ')
260
- else
261
- issue.location&.join(', ')
262
- end
263
-
264
- location_prefix = resource.id ? "#{resource.resourceType}/#{resource.id}" : resource.resourceType
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
- "#{location_prefix}: #{location}: #{issue&.details&.text}"
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
- # @private
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, add_messages_to_runnable:)
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
- def resource_is_valid?(resource, profile_url, runnable, add_messages_to_runnable: true) # rubocop:disable Metrics/CyclomaticComplexity
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
- response_body = validator.validate(FHIR::Patient.new, 'http://hl7.org/fhir/StructureDefinition/Patient')
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
@@ -1,4 +1,4 @@
1
1
  module Inferno
2
2
  # Standard patterns for gem versions: https://guides.rubygems.org/patterns/
3
- VERSION = '1.0.8'.freeze
3
+ VERSION = '1.1.0'.freeze
4
4
  end
data/spec/spec_helper.rb CHANGED
@@ -14,6 +14,7 @@ SimpleCov.start do
14
14
  add_filter '/lib/inferno/db/schema.rb'
15
15
  add_filter '/lib/inferno/apps/cli'
16
16
  add_filter '/lib/inferno/ext/rack.rb'
17
+ add_filter '/dev_suites/dev_validator_suite/validator_suite.rb'
17
18
  end
18
19
 
19
20
  if ENV['GITHUB_ACTIONS']
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.8
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-02-06 00:00:00.000000000 Z
13
+ date: 2026-03-05 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: activesupport