Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FI-2233: New validation module for HL7 validator wrapper #401

Merged
merged 7 commits into from
Nov 13, 2023
Merged
Show file tree
Hide file tree
Changes from 5 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
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:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

config/nginx.background.conf will need an entry for this as well.

Copy link
Contributor Author

@dehall dehall Nov 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added an entry plus comments linking the two since it's necessary to enable both at the same time. Note I set the timeout to 600s both in the nginx conf and the actual HTTP call, I know that's way too long but until we get session-startup-magic working it needs to be fairly high.

# image: markiantorno/validator-wrapper
# # Update this path to match your directory structure
# volumes:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the hl7 wrapper need this volume?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone wants to load an IG into the validator from a file instead of from a published version, then this is necessary. To reference the IG you can provide a filename:

      fhir_resource_validator do
        igs ['igs/30-us.core-3.1.1.tgz']
        ...
      end

# - ./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('VALIDATOR_URL')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make this FHIR_RESOURCE_VALIDATOR_URL and add it to .env so both services can be used at the same time without any risk of conflicts.

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: 'manually_entered_file.json',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make this something like ResourceType/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']
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a ticket to handle this across processes. It will need to support multiple worker processes like we have in prod, and I expect the web process will need access too in order to display the validator status in the UI.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created FI-2311 for this


# 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
Loading