Skip to content

Commit

Permalink
FI-2233: New validation module for HL7 validator wrapper (#401)
Browse files Browse the repository at this point in the history
* FI-2233: step 1, clone Inferno::DSL::FHIRValidation to ::FHIRResourceValidation

* reapply changed from PoC

* update unit tests

* wire everything up

* added commented-out nginx conf for hl7 validator endpoint

* review feedback

* update tests with new validator filename format
  • Loading branch information
dehall authored Nov 13, 2023
1 parent 8a50537 commit 913467e
Show file tree
Hide file tree
Showing 7 changed files with 526 additions and 1 deletion.
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
VALIDATOR_URL=http://localhost/validatorapi
REDIS_URL=redis://localhost:6379/0
G10_VALIDATOR_URL=http://localhost/validatorapi
FHIR_RESOURCE_VALIDATOR_URL=http://localhost/hl7validatorapi

# The base path where inferno will be hosted. Leave blank to host inferno at the
# root of its host.
Expand Down
18 changes: 18 additions & 0 deletions config/nginx.background.conf
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,23 @@ http {

proxy_pass http://validator_service:4567/;
}

# To enable the HL7 Validator Wrapper, both the section below and
# the section in docker-compose.background.yml need to be uncommented
# location /hl7validatorapi/ {
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header Host $http_host;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header X-Forwarded-Port $server_port;
# proxy_redirect off;
# proxy_set_header Connection '';
# proxy_http_version 1.1;
# chunked_transfer_encoding off;
# proxy_buffering off;
# proxy_cache off;
# proxy_read_timeout 600s;
#
# proxy_pass http://hl7_validator_service:3500/;
# }
}
}
7 changes: 7 additions & 0 deletions docker-compose.background.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,10 @@ services:
volumes:
- ./data/redis:/data
command: redis-server --appendonly yes
# To enable the HL7 Validator Wrapper, both the section below and
# the section in nginx.background.conf need to be uncommented
# hl7_validator_service:
# image: markiantorno/validator-wrapper
# # Update this path to match your directory structure
# volumes:
# - ./igs:/home/igs
4 changes: 3 additions & 1 deletion lib/inferno/dsl.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
require_relative 'dsl/assertions'
require_relative 'dsl/fhir_client'
require_relative 'dsl/fhir_validation'
require_relative 'dsl/fhir_resource_validation'
require_relative 'dsl/http_client'
require_relative 'dsl/results'
require_relative 'dsl/runnable'
Expand All @@ -13,7 +14,8 @@ module DSL
FHIRClient,
HTTPClient,
Results,
FHIRValidation
FHIRValidation,
FHIRResourceValidation
].freeze

EXTENDABLE_DSL_MODULES = [
Expand Down
298 changes: 298 additions & 0 deletions lib/inferno/dsl/fhir_resource_validation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
require_relative '../ext/fhir_models'
module Inferno
module DSL
# This module contains the methods needed to configure a validator to
# perform validation of FHIR resources. The actual validation is performed
# by an external FHIR validation service. Tests will typically rely on
# `assert_valid_resource` for validation rather than directly calling
# methods on a validator.
#
# @example
#
# validator do
# url 'http://example.com/validator'
# exclude_message { |message| message.type == 'info' }
# perform_additional_validation do |resource, profile_url|
# if something_is_wrong
# { type: 'error', message: 'something is wrong' }
# else
# { type: 'info', message: 'everything is ok' }
# end
# end
# end
module FHIRResourceValidation
def self.included(klass)
klass.extend ClassMethods
end

class Validator
attr_reader :requirements

# @private
def initialize(requirements = nil, &)
instance_eval(&)
@requirements = requirements
end

# @private
def default_validator_url
ENV.fetch('FHIR_RESOURCE_VALIDATOR_URL')
end

# Set the url of the validator service
#
# @param validator_url [String]
def url(validator_url = nil)
@url = validator_url if validator_url

@url
end

# Set the IGs that the validator will need to load
# Example: ["hl7.fhir.us.core#4.0.0"]
# @param igs [Array<String>]
def igs(validator_igs = nil)
@igs = validator_igs if validator_igs

@igs
end

# @private
def additional_validations
@additional_validations ||= []
end

# Perform validation steps in addition to FHIR validation.
#
# @example
# perform_additional_validation do |resource, profile_url|
# if something_is_wrong
# { type: 'error', message: 'something is wrong' }
# else
# { type: 'info', message: 'everything is ok' }
# end
# end
# @yieldparam resource [FHIR::Model] the resource being validated
# @yieldparam profile_url [String] the profile the resource is being
# validated against
# @yieldreturn [Array<Hash<Symbol, String>>,Hash<Symbol, String>] The
# block should return a Hash or an Array of Hashes if any validation
# messages should be added. The Hash must contain two keys: `:type`
# and `:message`. `:type` can have a value of `'info'`, `'warning'`,
# or `'error'`. A type of `'error'` means the resource is invalid.
# `:message` contains the message string itself.
def perform_additional_validation(&block)
additional_validations << block
end

# @private
def additional_validation_messages(resource, profile_url)
additional_validations
.flat_map { |step| step.call(resource, profile_url) }
.select { |message| message.is_a? Hash }
end

# Filter out unwanted validation messages. Any messages for which the
# block evalutates to a truthy value will be excluded.
#
# @example
# validator do
# exclude_message { |message| message.type == 'info' }
# end
# @yieldparam message [Inferno::Entities::Message]
def exclude_message(&block)
@exclude_message = block if block_given?
@exclude_message
end

# @see Inferno::DSL::FHIRResourceValidation#resource_is_valid?
def resource_is_valid?(resource, profile_url, runnable)
profile_url ||= FHIR::Definitions.resource_definition(resource.resourceType).url

begin
response = call_validator(resource, profile_url)
rescue StandardError => e
# This could be a complete failure to connect (validator isn't running)
# or a timeout (validator took too long to respond).
runnable.add_message('error', e.message)
raise Inferno::Exceptions::ErrorInValidatorException, "Unable to connect to validator at #{url}."
end
outcome = operation_outcome_from_validator_response(response, runnable)

message_hashes = message_hashes_from_outcome(outcome, resource, profile_url)

message_hashes
.each { |message_hash| runnable.add_message(message_hash[:type], message_hash[:message]) }

unless response.status == 200
raise Inferno::Exceptions::ErrorInValidatorException,
'Error occurred in the validator. Review Messages tab or validator service logs for more information.'
end

message_hashes
.none? { |message_hash| message_hash[:type] == 'error' }
rescue Inferno::Exceptions::ErrorInValidatorException
raise
rescue StandardError => e
runnable.add_message('error', e.message)
raise Inferno::Exceptions::ErrorInValidatorException,
'Error occurred in the validator. Review Messages tab or validator service logs for more information.'
end

# @private
def filter_messages(message_hashes)
message_hashes.reject! { |message| exclude_message.call(Entities::Message.new(message)) } if exclude_message
end

# @private
def message_hashes_from_outcome(outcome, resource, profile_url)
message_hashes = outcome.issue&.map { |issue| message_hash_from_issue(issue, resource) } || []

message_hashes.concat(additional_validation_messages(resource, profile_url))

filter_messages(message_hashes)

message_hashes
end

# @private
def message_hash_from_issue(issue, resource)
{
type: issue_severity(issue),
message: issue_message(issue, resource)
}
end

# @private
def issue_severity(issue)
case issue.severity
when 'warning'
'warning'
when 'information'
'info'
else
'error'
end
end

# @private
def issue_message(issue, resource)
location = if issue.respond_to?(:expression)
issue.expression&.join(', ')
else
issue.location&.join(', ')
end

location_prefix = resource.id ? "#{resource.resourceType}/#{resource.id}" : resource.resourceType

"#{location_prefix}: #{location}: #{issue&.details&.text}"
end

# @private
def wrap_resource_for_hl7_wrapper(resource, profile_url)
wrapped_resource = {
cliContext: {
# TODO: these should be configurable as well
sv: '4.0.1',
# displayWarnings: true, # -display-issues-are-warnings
# txServer: nil, # -tx n/a
igs: @igs || [],
# NOTE: this profile must be part of a loaded IG,
# otherwise the response is an HTTP 500 with no content
profiles: [profile_url]
},
filesToValidate: [
{
fileName: "#{resource.resourceType}/#{resource.id}.json",
fileContent: resource.to_json,
fileType: 'json'
}
],
sessionId: @session_id
}
wrapped_resource.to_json
end

# Post a resource to the validation service for validating.
#
# @param resource [FHIR::Model]
# @param profile_url [String]
# @return [String] the body of the validation response
def validate(resource, profile_url)
call_validator(resource, profile_url).body
end

# @private
def call_validator(resource, profile_url)
request_body = wrap_resource_for_hl7_wrapper(resource, profile_url)
Faraday.new(
url,
request: { timeout: 600 }
).post('validate', request_body, content_type: 'application/json')
end

# @private
def operation_outcome_from_hl7_wrapped_response(response)
res = JSON.parse(response)

@session_id = res['sessionId']

# assume for now that one resource -> one request
issues = res['outcomes'][0]['issues']&.map do |i|
{ severity: i['level'].downcase, expression: i['location'], details: { text: i['message'] } }
end
# this is circuitous, ideally we would map this response directly to message_hashes
FHIR::OperationOutcome.new(issue: issues)
end

# @private
def operation_outcome_from_validator_response(response, runnable)
if response.body.start_with? '{'
operation_outcome_from_hl7_wrapped_response(response.body)
else
runnable.add_message('error', "Validator Response: HTTP #{response.status}\n#{response.body}")
raise Inferno::Exceptions::ErrorInValidatorException,
'Validator response was an unexpected format. '\
'Review Messages tab or validator service logs for more information.'
end
end
end

module ClassMethods
# @private
def fhir_validators
@fhir_validators ||= {}
end

# Define a validator
# @example
# fhir_resource_validator do
# url 'http://example.com/validator'
# exclude_message { |message| message.type == 'info' }
# perform_additional_validation do |resource, profile_url|
# if something_is_wrong
# { type: 'error', message: 'something is wrong' }
# else
# { type: 'info', message: 'everything is ok' }
# end
# end
# end
#
# @param name [Symbol] the name of the validator, only needed if you are
# using multiple validators
# @param required_suite_options [Hash] suite options that must be
# selected in order to use this validator
def fhir_resource_validator(name = :default, required_suite_options: nil, &block)
current_validators = fhir_validators[name] || []

new_validator = Inferno::DSL::FHIRResourceValidation::Validator.new(required_suite_options, &block)

current_validators.reject! { |validator| validator.requirements == required_suite_options }
current_validators << new_validator

fhir_validators[name] = current_validators
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/inferno/entities/test_suite.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class TestSuite
extend DSL::FHIRClient::ClassMethods
extend DSL::HTTPClient::ClassMethods
include DSL::FHIRValidation
include DSL::FHIRResourceValidation

class << self
extend Forwardable
Expand Down
Loading

0 comments on commit 913467e

Please sign in to comment.