Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 98 additions & 22 deletions lib/davinci_pas_test_kit/cross_suite/pas_bundle_validation.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require 'inferno/dsl/fhir_resource_navigation'

require_relative '../parameters_helper'
require_relative 'validation_test'
require_relative 'pas_constants'
Expand All @@ -9,6 +11,15 @@ module PasBundleValidation
include DaVinciPASTestKit::ValidationTest
include ParametersHelper

class ReferenceNavigationContext
include Inferno::DSL::FHIRResourceNavigation
attr_accessor :metadata

def initialize(metadata)
self.metadata = metadata
end
end

US_CORE_VERSION = '6.1.0'
US_CORE_PROFILE_BASE = 'http://hl7.org/fhir/us/core/StructureDefinition'
BASE_R4_PROFILE = :base_r4
Expand Down Expand Up @@ -96,7 +107,7 @@ def perform_request_validation(bundle, profile_url, version, request_type)
end

def perform_response_validation(response_bundle, profile_url, version, request_type, request_bundle = nil)
validate_pa_response_body_structure(response_bundle, request_bundle) if request_type == 'submit'
validate_pa_response_body_structure(response_bundle, request_bundle) if submit_operation?(request_type)
validate_resources_conformance_against_profile(response_bundle, profile_url, version, request_type)
end

Expand All @@ -106,7 +117,7 @@ def validate_pas_bundle_json(json, profile_url, version, request_type, bundle_ty
assert resource.present?, 'Not a FHIR resource'

# For v2.2.0 inquire responses, expect Parameters resource
if version == '2.2.0' && request_type == 'inquire' && bundle_type == 'response_bundle'
if version == '2.2.0' && inquire_operation?(request_type) && bundle_type == 'response_bundle'
if resource.resourceType == 'Parameters'
# Extract and validate each Bundle in the Parameters
bundles = extract_bundles_from_pas_inquiry_response_parameters(resource)
Expand Down Expand Up @@ -166,7 +177,7 @@ def validate_pa_request_payload_structure(bundle, request_type)

check_presence_of_referenced_resources(first_entry, base_url, bundle.entry)

if request_type == 'submit'
if submit_operation?(request_type)
unless first_entry.is_a?(FHIR::Claim)
validation_error_messages << "[Bundle/#{bundle.id}]: The first bundle entry must be a Claim"
end
Expand Down Expand Up @@ -496,7 +507,7 @@ def extract_profiles_to_validate_each_entry(bundle_entry, current_entry, current
end

reference_elements.each do |reference_element|
process_reference_element(reference_element, current_entry, bundle_entry, bundle_map, version)
process_reference_element(reference_element, current_entry, bundle_entry, bundle_map, version, metadata)
end
end

Expand All @@ -509,7 +520,7 @@ def handle_claim_profile(reference_elements, current_entry_profile_url)
return unless current_entry_profile_url == PASConstants::CLAIM_PROFILE

claim_ref_element = {
path: 'Claim.item.extension.value',
path: 'Claim.item.extension:requestedService.value[x]',
profiles: [
'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-medicationrequest',
'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-servicerequest',
Expand All @@ -518,30 +529,23 @@ def handle_claim_profile(reference_elements, current_entry_profile_url)
]
}

# This temporary traversal handling may be replaced later by shared metadata-driven navigation logic.
claim_encounter_ref_element = {
path: "Claim.extension.where(url = '#{CLAIM_ENCOUNTER_EXTENSION_URL}').value",
profiles: [
'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-encounter'
]
}

reference_elements << claim_ref_element
reference_elements << claim_encounter_ref_element
end

# Processes a given reference element definition from a FHIR bundle entry.
# It evaluates FHIRPath expressions and processes each referenced instance and its profiles.
# It walks each reference path and processes referenced instances and their profiles.
# @param reference_element [Hash] The reference element to process.
# @param current_entry [Object] The current entry within the FHIR bundle.
# @param bundle_entry [Array] The bundle.entry.
# @param bundle_map [Hash] A map of the bundle contents.
# @param version [String] The FHIR version.
def process_reference_element(reference_element, current_entry, bundle_entry, bundle_map, version)
fhirpath_result = evaluate_fhirpath(resource: current_entry.resource, path: reference_element[:path])
reference_element_values = fhirpath_result.filter_map do |entry|
entry['element']&.reference if entry['type'] == 'Reference'
end
def process_reference_element(reference_element, current_entry, bundle_entry, bundle_map, version,
profile_metadata = nil)
reference_element_values = reference_values_for_element(
current_entry.resource,
reference_element,
profile_metadata
)

referenced_instances = reference_element_values.filter_map do |value|
find_referenced_instance_in_bundle(value, current_entry.fullUrl, bundle_map)
Expand All @@ -552,6 +556,65 @@ def process_reference_element(reference_element, current_entry, bundle_entry, bu
end
end

def reference_values_for_element(resource, reference_element, profile_metadata = nil)
navigation_context = reference_navigation_context(profile_metadata)
path = navigation_path_for_reference(resource, reference_element, profile_metadata, navigation_context)

Array.wrap(navigation_context.resolve_path(resource, path))
.select { |element| element.is_a?(FHIR::Reference) && element.reference.present? }
.map(&:reference)
end

def navigation_path_for_reference(resource, reference_element, profile_metadata = nil,
navigation_context = reference_navigation_context(profile_metadata))
path = resource_relative_reference_path(resource, reference_element[:path])
navigation_compatible_reference_path(path, profile_metadata, navigation_context)
end

def resource_relative_reference_path(resource, path)
path = path.to_s
resource_prefix = "#{resource.resourceType}."
path.start_with?(resource_prefix) ? path.delete_prefix(resource_prefix) : path
end

def navigation_compatible_reference_path(path, profile_metadata = nil,
navigation_context = reference_navigation_context(profile_metadata))
logical_segments = []

navigation_context.path_segments(path).map do |segment|
normalized_segment = normalized_reference_path_segment(segment, logical_segments, profile_metadata)
logical_segments << segment.split(':').first
normalized_segment
end.join('.')
end

def normalized_reference_path_segment(segment, logical_segments, profile_metadata)
extension_type, extension_name = segment.match(/\A(modifierExtension|extension):(.+)\z/)&.captures
return segment if extension_type.blank?

extension_path = [logical_segments.join('.'), extension_type].reject(&:blank?).join('.')
extension_url = extension_url_for_reference_path(profile_metadata, extension_path, extension_type, extension_name)
return segment if extension_url.blank?

"#{extension_type}.where(url='#{extension_url_without_version(extension_url)}')"
end

def extension_url_for_reference_path(profile_metadata, extension_path, extension_type, extension_name)
extensions = Array.wrap(profile_metadata&.must_supports&.dig(:extensions))
path_matching_extensions = extensions.select { |ext| ext[:path] == extension_path }
suffix = "#{extension_type}:#{extension_name}"
match = path_matching_extensions.find { |ext| ext[:id].to_s.end_with?(suffix) }
match[:url] if match.present?
end

def extension_url_without_version(url)
url.to_s.split('|', 2).first
end

def reference_navigation_context(profile_metadata = nil)
ReferenceNavigationContext.new(profile_metadata)
end

# Processes the profiles associated with a given instance in a FHIR bundle.
# It adds the instance's profiles to the resource target profile map and handles recursive profile extraction.
# The profiles collected here are possible profiles the given instance may conform to.
Expand Down Expand Up @@ -664,16 +727,29 @@ def extract_base_url(absolute_url)

# Resource Types to validate in request/ response bundle
def find_profile_url(request_type)
submit_operation = submit_operation?(request_type)
{
'Claim' => request_type == 'submit' ? PASConstants::CLAIM_PROFILE : PASConstants::CLAIM_INQUIRY_PROFILE,
'ClaimResponse' => if request_type == 'submit'
'Claim' => submit_operation ? PASConstants::CLAIM_PROFILE : PASConstants::CLAIM_INQUIRY_PROFILE,
'ClaimResponse' => if submit_operation
PASConstants::CLAIM_RESPONSE_PROFILE
else
PASConstants::CLAIM_INQUIRY_RESPONSE_PROFILE
end
}
end

def submit_operation?(request_type)
request_operation(request_type) == 'submit'
end

def inquire_operation?(request_type)
request_operation(request_type) == 'inquire'
end

def request_operation(request_type)
request_type.to_s.split('_').first
end

# Checks the following requirement:
# The Claim.supportingInfo.sequence for each entry SHALL be unique within the Claim.
#
Expand Down
109 changes: 81 additions & 28 deletions spec/davinci_pas_test_kit/cross_suite/pas_bundle_validation_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -249,10 +249,6 @@ def resource_type
Class.new do
include DaVinciPASTestKit::PasBundleValidation

def evaluate_fhirpath(**)
[]
end

def messages
@messages ||= []
end
Expand Down Expand Up @@ -289,8 +285,36 @@ def bundle_entry(resource, full_url)
)
end

def claim_encounter_fhirpath
"Claim.extension.where(url = '#{described_class::CLAIM_ENCOUNTER_EXTENSION_URL}').value"
def reference_extension(url, reference)
FHIR::Extension.new(
url:,
valueReference: FHIR::Reference.new(reference:)
)
end

def claim_with_encounter_reference(reference)
FHIR::Claim.new(
id: 'claim-1',
extension: [
reference_extension(described_class::CLAIM_ENCOUNTER_EXTENSION_URL, reference)
]
)
end

def claim_with_requested_service_reference(reference)
FHIR::Claim.new(
id: 'claim-1',
item: [
FHIR::Claim::Item.new(
extension: [
reference_extension(
'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/extension-requestedService',
reference
)
]
)
]
)
end

describe '#us_core_profile_fallback_enabled?' do
Expand All @@ -304,6 +328,22 @@ def claim_encounter_fhirpath
end
end

describe '#find_profile_url' do
it 'treats compound submit request types as submit operations' do
expect(test_instance.send(:find_profile_url, 'submit_request')['Claim'])
.to eq(DaVinciPASTestKit::PASConstants::CLAIM_PROFILE)
expect(test_instance.send(:find_profile_url, 'submit_response')['ClaimResponse'])
.to eq(DaVinciPASTestKit::PASConstants::CLAIM_RESPONSE_PROFILE)
end

it 'treats compound inquire request types as inquiry operations' do
expect(test_instance.send(:find_profile_url, 'inquire_request')['Claim'])
.to eq(DaVinciPASTestKit::PASConstants::CLAIM_INQUIRY_PROFILE)
expect(test_instance.send(:find_profile_url, 'inquire_response')['ClaimResponse'])
.to eq(DaVinciPASTestKit::PASConstants::CLAIM_INQUIRY_RESPONSE_PROFILE)
end
end

describe '#us_core_condition_profile_ids' do
it 'uses the encounter diagnosis profile for matching encounter-diagnosis category' do
condition = FHIR::Condition.new(
Expand Down Expand Up @@ -484,7 +524,6 @@ def claim_encounter_fhirpath
)

allow(test_instance).to receive(:validate_bundle_entries_against_profiles)
allow(test_instance).to receive(:evaluate_fhirpath).and_return([])

test_instance.validate_resources_conformance_against_profile(
bundle,
Expand Down Expand Up @@ -523,7 +562,7 @@ def claim_encounter_fhirpath
end

it 'resolves versioned PAS reference target profiles against unversioned metadata keys' do
claim = FHIR::Claim.new(id: 'claim-1')
claim = claim_with_encounter_reference('Encounter/encounter-1')
encounter = FHIR::Encounter.new(id: 'encounter-1')
bundle = FHIR::Bundle.new(
entry: [
Expand All @@ -533,13 +572,6 @@ def claim_encounter_fhirpath
)

allow(test_instance).to receive(:validate_bundle_entries_against_profiles)
allow(test_instance).to receive(:evaluate_fhirpath) do |path:, **|
if path == 'Claim.extension.value[x]'
[{ 'type' => 'Reference', 'element' => FHIR::Reference.new(reference: 'Encounter/encounter-1') }]
else
[]
end
end

test_instance.validate_resources_conformance_against_profile(
bundle,
Expand All @@ -554,24 +586,17 @@ def claim_encounter_fhirpath
).to contain_exactly('http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-encounter|2.2.0')
end

it 'traverses the R5 Claim encounter extension to the PAS Encounter profile' do
claim = FHIR::Claim.new(id: 'claim-1')
encounter = FHIR::Encounter.new(id: 'encounter-1')
it 'traverses Claim requestedService extensions to the PAS ServiceRequest profile before US Core fallback' do
claim = claim_with_requested_service_reference('ServiceRequest/service-request-1')
service_request = FHIR::ServiceRequest.new(id: 'service-request-1')
bundle = FHIR::Bundle.new(
entry: [
bundle_entry(claim, 'http://example.com/fhir/Claim/claim-1'),
bundle_entry(encounter, 'http://example.com/fhir/Encounter/encounter-1')
bundle_entry(service_request, 'http://example.com/fhir/ServiceRequest/service-request-1')
]
)

allow(test_instance).to receive(:validate_bundle_entries_against_profiles)
allow(test_instance).to receive(:evaluate_fhirpath) do |path:, **|
if path == claim_encounter_fhirpath
[{ 'type' => 'Reference', 'element' => FHIR::Reference.new(reference: 'Encounter/encounter-1') }]
else
[]
end
end

test_instance.validate_resources_conformance_against_profile(
bundle,
Expand All @@ -582,8 +607,36 @@ def claim_encounter_fhirpath

expect(
test_instance
.bundle_resources_target_profile_map['http://example.com/fhir/Encounter/encounter-1'][:profile_urls]
).to contain_exactly('http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-encounter')
.bundle_resources_target_profile_map['http://example.com/fhir/ServiceRequest/service-request-1'][:profile_urls]
).to contain_exactly('http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-servicerequest')
end

it 'traverses Claim requestedService extensions for submit_request server validation' do
claim = claim_with_requested_service_reference('ServiceRequest/service-request-1')
service_request = FHIR::ServiceRequest.new(id: 'service-request-1')
bundle = FHIR::Bundle.new(
entry: [
bundle_entry(claim, 'http://example.com/fhir/Claim/claim-1'),
bundle_entry(service_request, 'http://example.com/fhir/ServiceRequest/service-request-1')
]
)

allow(test_instance).to receive(:validate_bundle_entries_against_profiles)

test_instance.validate_resources_conformance_against_profile(
bundle,
'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-pas-request-bundle',
'2.2.0',
'submit_request'
)

expect(
test_instance.bundle_resources_target_profile_map['http://example.com/fhir/Claim/claim-1'][:profile_urls]
).to include(DaVinciPASTestKit::PASConstants::CLAIM_PROFILE)
expect(
test_instance
.bundle_resources_target_profile_map['http://example.com/fhir/ServiceRequest/service-request-1'][:profile_urls]
).to contain_exactly('http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-servicerequest')
end

it 'uses the base R4 sentinel to validate without a profile URL' do
Expand Down