Skip to content
This repository has been archived by the owner on Dec 1, 2023. It is now read-only.

Commit

Permalink
FI-930: Support FHIRPath for generating searches (onc-healthit#490)
Browse files Browse the repository at this point in the history
* support fhirpath for search generation

* regenerate us core sequences

Co-authored-by: Stephen MacVicar <[email protected]>
  • Loading branch information
jason-crowley and Jammjammjamm authored Oct 2, 2020
1 parent 5d103e0 commit 9e2e2f7
Show file tree
Hide file tree
Showing 95 changed files with 1,664 additions and 942 deletions.
8 changes: 7 additions & 1 deletion config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ modules:
- argonaut
- uscore_v3.1.0
- uscore_v3.1.1

# FHIRPath evaluator options: must be one of "internal" or "external".
# external_fhirpath_evaluator_url is only used if fhirpath_evaluator is set to
# external.
fhirpath_evaluator: external
external_fhirpath_evaluator_url: http://validator_service:4567

# Optional preset server testing configurations.
#
Expand Down Expand Up @@ -147,4 +153,4 @@ presets:
client_id: SAMPLE_CONFIDENTIAL_CLIENT_ID
confidential_client: true
client_secret: SAMPLE_CONFIDENTIAL_CLIENT_SECRET
patient_ids: "76,185"
patient_ids: "76,185"
15 changes: 4 additions & 11 deletions generator/generic/search_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,8 @@ def create_search_tests(metadata)

search_parameter_assignments = search[:parameters].map do |parameter|
param_metadata = metadata.search_parameter_metadata.find { |parameter_metadata| parameter_metadata.code == parameter }
path = param_metadata
.expression
.gsub(/(?<!\w)class(?!\w)/, 'local_class')
.split('.')
.drop(1)
.join('.')
path = param_metadata.expression
path = path.gsub(/\.where\(.*\)/, '') # TODO: remove once FHIRPath resolve() function is handled

"#{search_param_value_name(parameter)} = find_search_parameter_value_from_resource(@resource_found, '#{path}')"
end
Expand All @@ -40,11 +36,8 @@ def create_search_tests(metadata)
end

def search_param_value_name(parameter)
# remove non-character elements from beginning and end of name
param_variable_name = parameter
.gsub(/^[\W_]+|[\W_]+$"/, '')
.tr('-', '_')
"#{param_variable_name}_val"
parameter.gsub!(/^[\W_]+|[\W_]+$"/, '') # remove non-character elements from beginning and end of name
"#{parameter.tr('-', '_')}_val"
end
end
end
Expand Down
4 changes: 0 additions & 4 deletions generator/generic_generator_utilities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,6 @@ def create_search_validation(sequence_metadata)
sequence_metadata.search_parameter_metadata&.each do |parameter_metadata|
type = sequence_metadata.element_type_by_path(parameter_metadata.expression) || parameter_metadata.type
path = parameter_metadata.expression
.gsub(/(?<!\w)class(?!\w)/, 'local_class')
.split('.')
.drop(1)
.join('.')
path += get_value_path_by_type(type) unless ['Period', 'date', 'HumanName', 'Address', 'CodeableConcept', 'Coding', 'Identifier'].include? type
parameter_code = parameter_metadata.code
resource_type = sequence_metadata.resource_type
Expand Down
10 changes: 2 additions & 8 deletions generator/uscore/metadata_extractor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ def add_must_support_elements(profile_definition, sequence)
type_code = type_element['type'].first['code']
must_support_element[:discriminator] = {
type: 'type',
code: capitalize_first_letter(type_code)
code: type_code.upcase_first
}
elsif discriminators.first['type'] == 'value'
must_support_element[:discriminator] = {
Expand Down Expand Up @@ -364,9 +364,7 @@ def add_search_param_descriptions(profile_definition, sequence)
sequence[:search_param_descriptions].each_key do |param|
search_param_definition = @resource_by_path[search_param_path(sequence[:resource], param.to_s)]
path = search_param_definition['expression']
path = path.gsub(/.where\((.*)/, '')
as_type = path.scan(/.as\((.*?)\)/).flatten.first
path = path.gsub(/.as\((.*?)\)/, capitalize_first_letter(as_type)) if as_type.present?
path = path.gsub(/\.where\(.*\)/, '') # TODO: remove once FHIRPath resolve() function is handled
profile_element = profile_definition['snapshot']['element'].select { |el| el['id'] == path }.first
param_metadata = {
path: path,
Expand Down Expand Up @@ -558,10 +556,6 @@ def set_first_search(sequence, params)
sequence[:searches].delete(search)
sequence[:searches].unshift(search)
end

def capitalize_first_letter(str)
str.slice(0).capitalize + str.slice(1..-1)
end
end
end
end
2 changes: 1 addition & 1 deletion generator/uscore/us_core_unit_test_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ def dynamic_search_params(search_params)
# get_value_for_search_param(resolve_element_from_path(@careplan_ary, 'category'))
# this method extracts the variable name '@careplan_ary' and the path 'category'
def dynamic_search_param(param_value)
match = param_value.match(/(@[^,]+).*'([\w\.]+)'/)
match = param_value.match(/(@[^,]+).*'([^']+)'/)
{
variable_name: match[1],
resource_path: match[2]
Expand Down
24 changes: 5 additions & 19 deletions generator/uscore/uscore_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -588,11 +588,8 @@ def create_must_support_test(sequence)

sequence[:must_supports][:elements].each do |element|
test[:description] += %(
* #{element[:path]})
# class is mapped to local_class in fhir_models. Update this after it
# has been added to the description so that the description contains
# the original path
element[:path] = element[:path].gsub(/(?<!\w)class(?!\w)/, 'local_class')
#{element[:path]}
)
end

must_support_extensions = sequence[:must_supports][:extensions]
Expand Down Expand Up @@ -865,8 +862,7 @@ def create_references_resolved_test(sequence)
end

def resolve_element_path(search_param_description, delayed_sequence)
element_path = search_param_description[:path].gsub(/(?<!\w)class(?!\w)/, 'local_class')
path_parts = element_path.split('.')
path_parts = search_param_description[:path].split('.')
resource_val = delayed_sequence ? "@#{path_parts.shift.underscore}_ary" : "@#{path_parts.shift.underscore}_ary[patient]"
"resolve_element_from_path(#{resource_val}, '#{path_parts.join('.')}')"
end
Expand Down Expand Up @@ -1000,13 +996,7 @@ def fixed_value_search_param(search_parameters, sequence)
name = search_parameters.find { |param| param != 'patient' }
search_description = sequence[:search_param_descriptions][name.to_sym]
values = search_description[:values]
path =
search_description[:path]
.split('.')
.drop(1)
.map { |path_part| path_part == 'class' ? 'local_class' : path_part }
.join('.')
path += get_value_path_by_type(search_description[:type])
path = search_description[:path] + get_value_path_by_type(search_description[:type])

{
name: name,
Expand Down Expand Up @@ -1166,18 +1156,14 @@ def skip_if_could_not_resolve(params)
end

def search_param_constants(search_parameters, sequence)
return { '_id': 'patient' } if search_parameters == ['_id'] && sequence[:resource] == 'Patient'
{ '_id': 'patient' } if search_parameters == ['_id'] && sequence[:resource] == 'Patient'
end

def create_search_validation(sequence)
search_validators = ''
sequence[:search_param_descriptions].each do |element, definition|
type = definition[:type]
path = definition[:path]
.gsub(/(?<!\w)class(?!\w)/, 'local_class')
.split('.')
.drop(1)
.join('.')
path += get_value_path_by_type(type) unless ['Period', 'date', 'HumanName', 'Address', 'CodeableConcept', 'Coding', 'Identifier'].include? type
search_validators += %(
when '#{element}'
Expand Down
2 changes: 2 additions & 0 deletions lib/app/endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require_relative 'helpers/configuration'
require_relative 'helpers/browser_logic'
require_relative 'utils/resource_validator_factory'
require_relative 'utils/fhirpath_evaluator_factory'

module Inferno
class App
Expand All @@ -20,6 +21,7 @@ class Endpoint < Sinatra::Base
Inferno::ENVIRONMENT = settings.environment
Inferno::PURGE_ON_RELOAD = settings.purge_database_on_reload
Inferno::RESOURCE_VALIDATOR = Inferno::App::ResourceValidatorFactory.new_validator(settings.resource_validator, settings.external_resource_validator_url)
Inferno::FHIRPATH_EVALUATOR = Inferno::App::FHIRPathEvaluatorFactory.new_evaluator(settings.fhirpath_evaluator, settings.external_fhirpath_evaluator_url)

if settings.logging_enabled
$stdout.sync = true # output in Docker is heavily delayed without this
Expand Down
36 changes: 4 additions & 32 deletions lib/app/sequence_base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -765,40 +765,12 @@ def check_resource_against_profile(resource, resource_type, specified_profile =
end

def resolve_path(elements, path)
elements = Array.wrap(elements)
return elements if path.blank?

paths = path.split('.')

elements.flat_map do |element|
resolve_path(element&.send(paths.first), paths.drop(1).join('.'))
end.compact
Inferno::FHIRPATH_EVALUATOR.evaluate(elements, path)
end

def resolve_element_from_path(element, path)
el_as_array = Array.wrap(element)
if path.empty?
return nil if element.nil?

return el_as_array.find { |el| yield(el) } if block_given?

return el_as_array.first
end

path_ary = path.split('.')
cur_path_part = path_ary.shift.to_sym
return nil if el_as_array.none? { |el| el.send(cur_path_part).present? }

el_as_array.each do |el|
el_found = if block_given?
resolve_element_from_path(el.send(cur_path_part), path_ary.join('.')) { |value_found| yield(value_found) }
else
resolve_element_from_path(el.send(cur_path_part), path_ary.join('.'))
end
return el_found unless el_found.blank?
end

nil
def resolve_element_from_path(element, path, &block)
elements = Inferno::FHIRPATH_EVALUATOR.evaluate(element, path)
block_given? ? elements.find(&block) : elements.first
end

def get_value_for_search_param(element, include_system = false)
Expand Down
30 changes: 30 additions & 0 deletions lib/app/utils/basic_fhirpath_evaluator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true

module Inferno
class BasicFHIRPathEvaluator
def evaluate(elements, path, patched = false)
path = patch_path(path) unless patched
elements = Array.wrap(elements)
return elements if path.blank?

first_path, *rest_paths = path.split('.')
rest_path = rest_paths.join('.')

elements.flat_map do |element|
evaluate(element&.send(first_path), rest_path, true)
end.compact
end

private

def patch_path(path)
path = path.dup
path.sub!(/^[A-Z]\w*\./, '')
path.gsub!(/\bclass\b/, 'local_class')
path.gsub!(/\.where\(.*\)/, '')
as_type = path.scan(/\.as\((.*?)\)/).flatten.first
path.gsub!(/\.as\((.*?)\)/, as_type.upcase_first) if as_type.present?
path
end
end
end
64 changes: 64 additions & 0 deletions lib/app/utils/fhirpath_evaluator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# frozen_string_literal: true

require 'rest-client'
require 'json'
require 'fhir_models'

module Inferno
class FHIRPathEvaluator
# @param fhirpath_url [String] the base url for the FHIRPath /evaluate endpoint
def initialize(fhirpath_url)
raise ArgumentError, 'FHIRPath URL is unset' if fhirpath_url.blank?

@fhirpath_url = fhirpath_url
end

# Evaluates the given FHIRPath expression against the given elements by posting each element
# to the FHIRPath wrapper.
# @param elements [Array]
# @param path [String]
def evaluate(elements, path)
elements = Array.wrap(elements)
return elements if path.blank?

types = elements.map { |e| e.class.name.demodulize }
Inferno.logger.info("Evaluating path '#{path}' on types: #{types}")

elements.flat_map do |element|
type = type_path(element)
Inferno.logger.info("POST #{@fhirpath_url}/evaluate?path=#{path}&type=#{type}")
result = RestClient.post "#{@fhirpath_url}/evaluate", element.to_json, params: { path: path, type: type }
collection = JSON.parse(result.body)

collection.map { |container| deserialize(container['element'], container['type']) }
end.compact
end

private

# Examples:
# type_path(FHIR::Patient.new) -> 'Patient'
# type_path(FHIR::Patient::Contact.new) -> 'Patient.contact'
# type_path(FHIR::RiskEvidenceSynthesis::Certainty::CertaintySubcomponent.new)
# -> 'RiskEvidenceSynthesis.certainty.certaintySubcomponent'
def type_path(element)
parts = element.class.name.split('::').drop(1)
# Assumes that BackboneElements are named by capitalizing path components
parts[1..-1].each { |part| part[0] = part[0].downcase }
parts.join('.')
end

def deserialize(element, type)
if element.is_a?(Hash)
first_component, *rest_components = type.split('.')
klass = FHIR.const_get(first_component)
rest_components.each do |component|
klass = FHIR.const_get(klass::METADATA[component]['type'])
end
klass.new(element)
else
element
end
end
end
end
21 changes: 21 additions & 0 deletions lib/app/utils/fhirpath_evaluator_factory.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

require_relative 'fhirpath_evaluator'
require_relative 'basic_fhirpath_evaluator'

module Inferno
class App
module FHIRPathEvaluatorFactory
def self.new_evaluator(selected_evaluator, external_evaluator_url)
return Inferno::BasicFHIRPathEvaluator.new if ENV['RACK_ENV'] == 'test'

case selected_evaluator
when 'internal'
Inferno::BasicFHIRPathEvaluator.new
when 'external'
Inferno::FHIRPathEvaluator.new(external_evaluator_url)
end
end
end
end
end
9 changes: 1 addition & 8 deletions lib/app/utils/sequence_utilities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,7 @@
module Inferno
module SequenceUtilities
def resolve_path(elements, path)
elements = Array.wrap(elements)
return elements if path.blank?

paths = path.split('.')

elements.flat_map do |element|
resolve_path(element&.send(paths.first), paths.drop(1).join('.'))
end.compact
Inferno::FHIRPATH_EVALUATOR.evaluate(elements, path)
end

def find_search_parameter_value_from_resource(resource, path)
Expand Down
Loading

0 comments on commit 9e2e2f7

Please sign in to comment.