From 9ad449eb6a93ae503c62c890928f64e49eb4326d Mon Sep 17 00:00:00 2001 From: Dylan Hall Date: Tue, 17 Dec 2024 12:16:29 -0500 Subject: [PATCH] FI-3440 review feedback, part 1 --- lib/inferno/apps/cli/evaluate.rb | 38 ++++-- lib/inferno/dsl/ig.rb | 90 ------------- lib/inferno/entities.rb | 1 + lib/inferno/entities/ig.rb | 147 ++++++++++++++++++++++ lib/inferno/repositories/igs.rb | 9 ++ lib/inferno/utils/ig_downloader.rb | 1 - spec/inferno/{dsl => entities}/ig_spec.rb | 2 +- 7 files changed, 186 insertions(+), 102 deletions(-) delete mode 100644 lib/inferno/dsl/ig.rb create mode 100644 lib/inferno/entities/ig.rb create mode 100644 lib/inferno/repositories/igs.rb rename spec/inferno/{dsl => entities}/ig_spec.rb (97%) diff --git a/lib/inferno/apps/cli/evaluate.rb b/lib/inferno/apps/cli/evaluate.rb index b3e931ef9..dd6aa1bb2 100644 --- a/lib/inferno/apps/cli/evaluate.rb +++ b/lib/inferno/apps/cli/evaluate.rb @@ -1,5 +1,5 @@ require_relative '../../../inferno/dsl/fhir_evaluation/evaluator' -require_relative '../../../inferno/dsl/ig' +require_relative '../../../inferno/entities' require_relative '../../utils/ig_downloader' require 'tempfile' @@ -12,15 +12,7 @@ class Evaluate < Thor::Group def evaluate(ig_path, data_path, _log_level) validate_args(ig_path, data_path) - - if File.file?(ig_path) - ig = Inferno::DSL::IG.from_file(ig_path) - else - Tempfile.create('package.tgz') do |temp_file| - load_ig(ig_path, nil, { force: true }, temp_file.path) - ig = Inferno::DSL::IG.from_file(temp_file.path) - end - end + _ig = get_ig(ig_path) # Rule execution, and result output below will be integrated soon. @@ -45,6 +37,32 @@ def validate_args(ig_path, data_path) raise "Provided path '#{data_path}' is not a directory" end + def get_ig(ig_path) + if File.exist?(ig_path) + ig = Inferno::Entities::IG.from_file(ig_path) + elsif in_user_package_cache?(ig_path.sub('@', '#')) + # NPM syntax for a package identifier is id@version (eg, hl7.fhir.us.core@3.1.1) + # but in the cache the separator is # (hl7.fhir.us.core#3.1.1) + cache_directory = File.join(user_package_cache, ig_path.sub('@', '#')) + ig = Inferno::Entities::IG.from_file(cache_directory) + else + Tempfile.create('package.tgz') do |temp_file| + load_ig(ig_path, nil, { force: true }, temp_file.path) + ig = Inferno::Entities::IG.from_file(temp_file.path) + end + end + ig.add_self_to_repository + ig + end + + def user_package_cache + File.join(Dir.home, '.fhir', 'packages') + end + + def in_user_package_cache?(ig_identifier) + File.directory?(File.join(user_package_cache, ig_identifier)) + end + def output_results(results, output) if output&.end_with?('json') oo = FhirEvaluator::EvaluationResult.to_operation_outcome(results) diff --git a/lib/inferno/dsl/ig.rb b/lib/inferno/dsl/ig.rb deleted file mode 100644 index cd767b467..000000000 --- a/lib/inferno/dsl/ig.rb +++ /dev/null @@ -1,90 +0,0 @@ -# frozen_string_literal: true - -require 'fhir_models' -require 'rubygems/package' -require 'zlib' - -module Inferno - module DSL - # IG is a wrapper class around the relevant concepts inside an IG. - # Not everything within an IG is currently used by Inferno. - class IG - attr_accessor :profiles, :extensions, :value_sets, :search_params, :examples - - def initialize - @profiles = [] - @extensions = [] - @value_sets = [] - @examples = [] - @search_params = [] - end - - def self.from_file(ig_path) - raise "#{ig_path} is not an IG file" unless File.file?(ig_path) - - tar = Gem::Package::TarReader.new( - Zlib::GzipReader.open(ig_path) - ) - - # fhir_models by default logs the entire content of non-FHIR files - # which could be things like a package.json - original_logger = FHIR.logger - FHIR.logger = Logger.new('/dev/null') - - ig = IG.new - - tar.each do |entry| - next if skip_tar_entry? entry - - begin - resource = FHIR::Json.from_json(entry.read) - next if resource.nil? - - ig.add_resource(resource, entry) - rescue StandardError - next - end - end - - ig - ensure - FHIR.logger = original_logger if defined? original_logger - end - - # These files aren't FHIR resources - FILES_TO_SKIP = ['package.json', 'validation-summary.json'].freeze - - def self.skip_tar_entry?(entry) - return true if entry.directory? - - file_name = entry.full_name.split('/').last - - # TODO: consider making these regexes we can iterate over in a single loop - return true unless file_name.end_with? '.json' - return true unless entry.full_name.start_with? 'package/' - - return true if file_name.start_with? '.' # ignore hidden files - return true if file_name.end_with? '.openapi.json' - return true if FILES_TO_SKIP.include? file_name - - false - end - - def add_resource(resource, entry) - if resource.resourceType == 'StructureDefinition' - if resource.type == 'Extension' - extensions.push resource - else - profiles.push resource - end - elsif resource.resourceType == 'ValueSet' - value_sets.push resource - elsif resource.resourceType == 'SearchParameter' - search_params.push resource - elsif entry.full_name.start_with? 'package/example' - examples.push resource - end - end - end - end -end diff --git a/lib/inferno/entities.rb b/lib/inferno/entities.rb index 23cc87ba1..70a4d98a7 100644 --- a/lib/inferno/entities.rb +++ b/lib/inferno/entities.rb @@ -2,6 +2,7 @@ require_relative 'entities/has_runnable' require_relative 'entities/entity' require_relative 'entities/header' +require_relative 'entities/ig' require_relative 'entities/message' require_relative 'entities/request' require_relative 'entities/result' diff --git a/lib/inferno/entities/ig.rb b/lib/inferno/entities/ig.rb new file mode 100644 index 000000000..041214dec --- /dev/null +++ b/lib/inferno/entities/ig.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require 'fhir_models' +require 'rubygems/package' +require 'zlib' + +require_relative '../repositories/igs' + +module Inferno + module Entities + # IG is a wrapper class around the relevant concepts inside an IG. + # Not everything within an IG is currently used by Inferno. + class IG < Entity + ATTRIBUTES = [ + :id, + :profiles, + :extensions, + :value_sets, + :search_params, + :examples + ].freeze + + include Inferno::Entities::Attributes + + def initialize(params) + super(params, ATTRIBUTES) + + @profiles = [] + @extensions = [] + @value_sets = [] + @examples = [] + @search_params = [] + end + + def self.from_file(ig_path) + raise "#{ig_path} does not exist" unless File.exist?(ig_path) + + # fhir_models by default logs the entire content of non-FHIR files + # which could be things like a package.json + original_logger = FHIR.logger + FHIR.logger = Logger.new('/dev/null') + + if File.directory?(ig_path) + from_directory(ig_path) + elsif ig_path.end_with? '.tgz' + from_tgz(ig_path) + else + raise "Unable to load #{ig_path} as it does not appear to be a directory or a .tgz file" + end + ensure + FHIR.logger = original_logger if defined? original_logger + end + + def self.from_tgz(ig_path) + tar = Gem::Package::TarReader.new( + Zlib::GzipReader.open(ig_path) + ) + + ig = IG.new({}) + + tar.each do |entry| + next if skip_item?(entry.full_name, entry.directory?) + + begin + resource = FHIR::Json.from_json(entry.read) + next if resource.nil? + + ig.handle_resource(resource, entry.full_name) + rescue StandardError + next + end + end + ig + end + + def self.from_directory(ig_path) + ig = IG.new({}) + + Dir.glob("#{ig_path}/**/*") do |f| + next if skip_item?(f, File.directory?(f)) + + begin + resource = FHIR::Json.from_json(File.read(f)) + next if resource.nil? + + ig.handle_resource(resource, f) + rescue StandardError + next + end + end + ig + end + + # These files aren't FHIR resources + FILES_TO_SKIP = ['package.json', 'validation-summary.json'].freeze + + def self.skip_item?(relative_path, is_directory) + return true if is_directory + + file_name = relative_path.split('/').last + + # TODO: consider making these regexes we can iterate over in a single loop + return true unless file_name.end_with? '.json' + return true unless relative_path.start_with? 'package/' + + return true if file_name.start_with? '.' # ignore hidden files + return true if file_name.end_with? '.openapi.json' + return true if FILES_TO_SKIP.include? file_name + + false + end + + def handle_resource(resource, relative_path) + case resource.resourceType + when 'StructureDefinition' + if resource.type == 'Extension' + extensions.push resource + else + profiles.push resource + end + when 'ValueSet' + value_sets.push resource + when 'SearchParameter' + search_params.push resource + when 'ImplementationGuide' + @id = extract_package_id(resource) + else + examples.push(resource) if relative_path.start_with? 'package/example' + end + end + + def extract_package_id(ig_resource) + "#{ig_resource.id}##{ig_resource.version || 'current'}" + end + + # @private + def add_self_to_repository + repository.insert(self) + end + + # @private + def repository + Inferno::Repositories::IGs.new + end + end + end +end diff --git a/lib/inferno/repositories/igs.rb b/lib/inferno/repositories/igs.rb new file mode 100644 index 000000000..3328a05d7 --- /dev/null +++ b/lib/inferno/repositories/igs.rb @@ -0,0 +1,9 @@ +require_relative 'in_memory_repository' + +module Inferno + module Repositories + # Repository that deals with persistence for the `IG` entity. + class IGs < InMemoryRepository + end + end +end diff --git a/lib/inferno/utils/ig_downloader.rb b/lib/inferno/utils/ig_downloader.rb index f9acd0bff..44cc8bfc0 100644 --- a/lib/inferno/utils/ig_downloader.rb +++ b/lib/inferno/utils/ig_downloader.rb @@ -25,7 +25,6 @@ def load_ig(ig_input, idx = nil, thor_config = { verbose: true }, output_path = else raise StandardError, <<~FAILED_TO_LOAD Could not find implementation guide: #{ig_input} - Put its package.tgz file directly in #{ig_path} FAILED_TO_LOAD end diff --git a/spec/inferno/dsl/ig_spec.rb b/spec/inferno/entities/ig_spec.rb similarity index 97% rename from spec/inferno/dsl/ig_spec.rb rename to spec/inferno/entities/ig_spec.rb index cc7b3b167..43a36b4c1 100644 --- a/spec/inferno/dsl/ig_spec.rb +++ b/spec/inferno/entities/ig_spec.rb @@ -1,4 +1,4 @@ -RSpec.describe Inferno::DSL::IG do +RSpec.describe Inferno::Entities::IG do let(:uscore3_package) { File.expand_path('../../fixtures/uscore311.tgz', __dir__) } describe '#from_file' do