Skip to content

Commit 89c7b38

Browse files
authored
FI-3440: Add IG entity and repository, integrated into Evaluate task (#573)
* FI-3440: Add IG class to DSL * FI-3440: Add IG spec * cleanup * FI-3440 review feedback, part 1 * Fix reading from directory * add test for loading IG from directory * review feedback
1 parent c5ec6ae commit 89c7b38

File tree

10 files changed

+277
-8
lines changed

10 files changed

+277
-8
lines changed

lib/inferno/apps/cli/evaluate.rb

+37-4
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
require_relative '../../../inferno/dsl/fhir_evaluation/evaluator'
2+
require_relative '../../../inferno/entities'
3+
require_relative '../../utils/ig_downloader'
4+
5+
require 'tempfile'
26

37
module Inferno
48
module CLI
5-
class Evaluate
6-
def run(ig_path, data_path, _log_level)
9+
class Evaluate < Thor::Group
10+
include Thor::Actions
11+
include Inferno::Utils::IgDownloader
12+
13+
def evaluate(ig_path, data_path, _log_level)
714
validate_args(ig_path, data_path)
15+
_ig = get_ig(ig_path)
816

9-
# IG Import, rule execution, and result output below will be integrated at phase 2 and 3.
17+
# Rule execution, and result output below will be integrated soon.
1018

11-
# @ig = File.join(__dir__, 'ig', ig_path)
1219
# if data_path
1320
# DatasetLoader.from_path(File.join(__dir__, data_path))
1421
# else
@@ -30,6 +37,32 @@ def validate_args(ig_path, data_path)
3037
raise "Provided path '#{data_path}' is not a directory"
3138
end
3239

40+
def get_ig(ig_path)
41+
if File.exist?(ig_path)
42+
ig = Inferno::Entities::IG.from_file(ig_path)
43+
elsif in_user_package_cache?(ig_path.sub('@', '#'))
44+
# NPM syntax for a package identifier is id@version (eg, hl7.fhir.us.core@3.1.1)
45+
# but in the cache the separator is # (hl7.fhir.us.core#3.1.1)
46+
cache_directory = File.join(user_package_cache, ig_path.sub('@', '#'))
47+
ig = Inferno::Entities::IG.from_file(cache_directory)
48+
else
49+
Tempfile.create('package.tgz') do |temp_file|
50+
load_ig(ig_path, nil, { force: true }, temp_file.path)
51+
ig = Inferno::Entities::IG.from_file(temp_file.path)
52+
end
53+
end
54+
ig.add_self_to_repository
55+
ig
56+
end
57+
58+
def user_package_cache
59+
File.join(Dir.home, '.fhir', 'packages')
60+
end
61+
62+
def in_user_package_cache?(ig_identifier)
63+
File.directory?(File.join(user_package_cache, ig_identifier))
64+
end
65+
3366
def output_results(results, output)
3467
if output&.end_with?('json')
3568
oo = FhirEvaluator::EvaluationResult.to_operation_outcome(results)

lib/inferno/apps/cli/main.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class Main < Thor
4545
type: :string,
4646
desc: 'Export evaluation result to outcome.json as an OperationOutcome'
4747
def evaluate(ig_path)
48-
Evaluate.new.run(ig_path, options[:data_path], Logger::INFO)
48+
Evaluate.new.evaluate(ig_path, options[:data_path], Logger::INFO)
4949
end
5050

5151
desc 'console', 'Start an interactive console session with Inferno'

lib/inferno/entities.rb

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
require_relative 'entities/has_runnable'
33
require_relative 'entities/entity'
44
require_relative 'entities/header'
5+
require_relative 'entities/ig'
56
require_relative 'entities/message'
67
require_relative 'entities/request'
78
require_relative 'entities/result'

lib/inferno/entities/ig.rb

+149
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# frozen_string_literal: true
2+
3+
require 'fhir_models'
4+
require 'pathname'
5+
require 'rubygems/package'
6+
require 'zlib'
7+
8+
require_relative '../repositories/igs'
9+
10+
module Inferno
11+
module Entities
12+
# IG is a wrapper class around the relevant concepts inside an IG.
13+
# Not everything within an IG is currently used by Inferno.
14+
class IG < Entity
15+
ATTRIBUTES = [
16+
:id,
17+
:profiles,
18+
:extensions,
19+
:value_sets,
20+
:search_params,
21+
:examples
22+
].freeze
23+
24+
include Inferno::Entities::Attributes
25+
26+
def initialize(**params)
27+
super(params, ATTRIBUTES)
28+
29+
@profiles = []
30+
@extensions = []
31+
@value_sets = []
32+
@examples = []
33+
@search_params = []
34+
end
35+
36+
def self.from_file(ig_path)
37+
raise "#{ig_path} does not exist" unless File.exist?(ig_path)
38+
39+
# fhir_models by default logs the entire content of non-FHIR files
40+
# which could be things like a package.json
41+
original_logger = FHIR.logger
42+
FHIR.logger = Logger.new('/dev/null')
43+
44+
if File.directory?(ig_path)
45+
from_directory(ig_path)
46+
elsif ig_path.end_with? '.tgz'
47+
from_tgz(ig_path)
48+
else
49+
raise "Unable to load #{ig_path} as it does not appear to be a directory or a .tgz file"
50+
end
51+
ensure
52+
FHIR.logger = original_logger if defined? original_logger
53+
end
54+
55+
def self.from_tgz(ig_path)
56+
tar = Gem::Package::TarReader.new(
57+
Zlib::GzipReader.open(ig_path)
58+
)
59+
60+
ig = IG.new
61+
62+
tar.each do |entry|
63+
next if skip_item?(entry.full_name, entry.directory?)
64+
65+
begin
66+
resource = FHIR::Json.from_json(entry.read)
67+
next if resource.nil?
68+
69+
ig.handle_resource(resource, entry.full_name)
70+
rescue StandardError
71+
next
72+
end
73+
end
74+
ig
75+
end
76+
77+
def self.from_directory(ig_directory)
78+
ig = IG.new
79+
80+
ig_path = Pathname.new(ig_directory)
81+
Dir.glob("#{ig_path}/**/*") do |f|
82+
relative_path = Pathname.new(f).relative_path_from(ig_path).to_s
83+
next if skip_item?(relative_path, File.directory?(f))
84+
85+
begin
86+
resource = FHIR::Json.from_json(File.read(f))
87+
next if resource.nil?
88+
89+
ig.handle_resource(resource, relative_path)
90+
rescue StandardError
91+
next
92+
end
93+
end
94+
ig
95+
end
96+
97+
# These files aren't FHIR resources
98+
FILES_TO_SKIP = ['package.json', 'validation-summary.json'].freeze
99+
100+
def self.skip_item?(relative_path, is_directory)
101+
return true if is_directory
102+
103+
file_name = relative_path.split('/').last
104+
105+
return true unless file_name.end_with? '.json'
106+
return true unless relative_path.start_with? 'package/'
107+
108+
return true if file_name.start_with? '.' # ignore hidden files
109+
return true if file_name.end_with? '.openapi.json'
110+
return true if FILES_TO_SKIP.include? file_name
111+
112+
false
113+
end
114+
115+
def handle_resource(resource, relative_path)
116+
case resource.resourceType
117+
when 'StructureDefinition'
118+
if resource.type == 'Extension'
119+
extensions.push resource
120+
else
121+
profiles.push resource
122+
end
123+
when 'ValueSet'
124+
value_sets.push resource
125+
when 'SearchParameter'
126+
search_params.push resource
127+
when 'ImplementationGuide'
128+
@id = extract_package_id(resource)
129+
else
130+
examples.push(resource) if relative_path.start_with? 'package/example'
131+
end
132+
end
133+
134+
def extract_package_id(ig_resource)
135+
"#{ig_resource.id}##{ig_resource.version || 'current'}"
136+
end
137+
138+
# @private
139+
def add_self_to_repository
140+
repository.insert(self)
141+
end
142+
143+
# @private
144+
def repository
145+
Inferno::Repositories::IGs.new
146+
end
147+
end
148+
end
149+
end

lib/inferno/repositories/igs.rb

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require_relative 'in_memory_repository'
2+
3+
module Inferno
4+
module Repositories
5+
# Repository that deals with persistence for the `IG` entity.
6+
class IGs < InMemoryRepository
7+
end
8+
end
9+
end

lib/inferno/utils/ig_downloader.rb

+3-3
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def ig_file(suffix = nil)
1414
File.join(ig_path, suffix ? "package_#{suffix}.tgz" : 'package.tgz')
1515
end
1616

17-
def load_ig(ig_input, idx = nil, thor_config = { verbose: true })
17+
def load_ig(ig_input, idx = nil, thor_config = { verbose: true }, output_path = nil)
1818
case ig_input
1919
when FHIR_PACKAGE_NAME_REG_EX
2020
uri = ig_registry_url(ig_input)
@@ -25,12 +25,12 @@ def load_ig(ig_input, idx = nil, thor_config = { verbose: true })
2525
else
2626
raise StandardError, <<~FAILED_TO_LOAD
2727
Could not find implementation guide: #{ig_input}
28-
Put its package.tgz file directly in #{ig_path}
2928
FAILED_TO_LOAD
3029
end
3130

31+
destination = output_path || ig_file(idx)
3232
# use Thor's get to support CLI options config
33-
get(uri, ig_file(idx), thor_config)
33+
get(uri, destination, thor_config)
3434
uri
3535
end
3636

spec/extract_tgz_helper.rb

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
module ExtractTGZHelper
2+
def extract_tgz(fixture)
3+
filename = File.basename(fixture, '.tgz')
4+
target_dir = Dir.mktmpdir(filename)
5+
system "mkdir -p #{target_dir}"
6+
system "tar -xzf #{fixture} --directory #{target_dir}"
7+
target_dir
8+
end
9+
10+
def cleanup(target_dir)
11+
FileUtils.remove_entry(target_dir)
12+
end
13+
end

spec/fixtures/uscore311.tgz

599 KB
Binary file not shown.

spec/inferno/entities/ig_spec.rb

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
require 'extract_tgz_helper'
2+
3+
RSpec.describe Inferno::Entities::IG do
4+
include ExtractTGZHelper
5+
6+
let(:uscore3_package) { File.expand_path('../../fixtures/uscore311.tgz', __dir__) }
7+
let(:uscore3_untarred) { extract_tgz(uscore3_package) }
8+
9+
after { cleanup(uscore3_untarred) }
10+
11+
describe '#from_file' do
12+
it 'loads an IG from tgz file' do
13+
ig = described_class.from_file(uscore3_package)
14+
expect_uscore3_loaded_properly(ig)
15+
end
16+
17+
it 'loads an IG from directory' do
18+
ig = described_class.from_file(uscore3_untarred)
19+
expect_uscore3_loaded_properly(ig)
20+
end
21+
22+
def expect_uscore3_loaded_properly(ig) # rubocop:disable Naming/MethodParameterName, Metrics/CyclomaticComplexity
23+
# For each artifact type in the IG, check:
24+
# the right number are loaded,
25+
# they're all the expected type,
26+
# and spot check a couple IDs
27+
28+
# https://www.hl7.org/fhir/us/core/STU3.1.1/profiles.html
29+
expect(ig.profiles.length).to eq(26)
30+
expect(ig.profiles.map(&:resourceType).uniq).to eq(['StructureDefinition'])
31+
expect(ig.profiles.map(&:id)).to include('us-core-patient', 'us-core-condition',
32+
'head-occipital-frontal-circumference-percentile')
33+
34+
expect(ig.extensions.length).to eq(4)
35+
expect(ig.extensions.map(&:resourceType).uniq).to eq(['StructureDefinition'])
36+
expect(ig.extensions.map(&:type).uniq).to eq(['Extension'])
37+
expect(ig.extensions.map(&:id)).to include('us-core-race', 'us-core-ethnicity')
38+
39+
# https://www.hl7.org/fhir/us/core/STU3.1.1/terminology.html
40+
expect(ig.value_sets.length).to eq(32)
41+
expect(ig.value_sets.map(&:resourceType).uniq).to eq(['ValueSet'])
42+
expect(ig.value_sets.map(&:id)).to include('us-core-usps-state', 'simple-language')
43+
44+
# https://www.hl7.org/fhir/us/core/STU3.1.1/searchparameters.html
45+
expect(ig.search_params.length).to eq(74)
46+
expect(ig.search_params.map(&:resourceType).uniq).to eq(['SearchParameter'])
47+
expect(ig.search_params.map(&:id)).to include('us-core-patient-name', 'us-core-encounter-id')
48+
49+
# https://www.hl7.org/fhir/us/core/STU3.1.1/all-examples.html
50+
expect(ig.examples.length).to eq(84)
51+
expect(ig.examples.map(&:id)).to include('child-example', 'self-tylenol')
52+
end
53+
end
54+
end

spec/inferno/utils/ig_downloader_spec.rb

+10
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ def with_temp_path(name)
7676
expect(File.read(temp_path)).to eq(package_binary)
7777
end
7878
end
79+
80+
it 'successfully downloads package to custom path' do
81+
stub_request(:get, 'https://packages.fhir.org/hl7.fhir.us.udap-security/-/hl7.fhir.us.udap-security-1.0.0.tgz')
82+
.to_return(body: package_binary)
83+
84+
with_temp_path('ig-downloader-canonical') do |temp_path|
85+
ig_downloader.load_ig(canonical, nil, { verbose: false }, temp_path)
86+
expect(File.read(temp_path)).to eq(package_binary)
87+
end
88+
end
7989
end
8090
end
8191

0 commit comments

Comments
 (0)