From 17537c9a823efb6afbbfaa336518b3ac14d08f78 Mon Sep 17 00:00:00 2001 From: Kenneth Myhra Date: Fri, 17 Jul 2026 17:01:25 +0200 Subject: [PATCH 01/16] Add central FHIR version registry --- lib/fhir_version.rb | 31 +++++++++++++++++++++++++ lib/plan_executor.rb | 1 + lib/tasks/tasks.rake | 5 +--- test/unit/fhir_version_test.rb | 42 ++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 lib/fhir_version.rb create mode 100644 test/unit/fhir_version_test.rb diff --git a/lib/fhir_version.rb b/lib/fhir_version.rb new file mode 100644 index 00000000..6646e19c --- /dev/null +++ b/lib/fhir_version.rb @@ -0,0 +1,31 @@ +module Crucible + module FHIRVersion + DEFAULT = :r4 + NAMESPACES = { + dstu2: 'FHIR::DSTU2', + stu3: 'FHIR::STU3', + r4: 'FHIR', + r4b: 'FHIR::R4B' + }.freeze + KNOWN = NAMESPACES.keys.freeze + + class UnsupportedVersionError < ArgumentError; end + + def self.resolve(value = nil) + normalized = value.to_s.strip.downcase + return DEFAULT if normalized.empty? + + version = normalized.to_sym + return version if KNOWN.include?(version) + + raise UnsupportedVersionError, + "Unsupported FHIR version '#{value}'. Supported versions: #{KNOWN.join(', ')}" + end + + def self.namespace(value = nil) + NAMESPACES.fetch(resolve(value)).split('::').inject(Object) do |parent, name| + parent.const_get(name) + end + end + end +end diff --git a/lib/plan_executor.rb b/lib/plan_executor.rb index 1c372a6a..bdfa8f2d 100644 --- a/lib/plan_executor.rb +++ b/lib/plan_executor.rb @@ -12,6 +12,7 @@ require 'active_support/core_ext' require 'jsonpath' +require_relative File.join('.','fhir_version.rb') require_relative File.join('.','executor.rb') require_relative File.join('.','test_result.rb') require_relative File.join('.','resource_generator.rb') diff --git a/lib/tasks/tasks.rake b/lib/tasks/tasks.rake index f8fb0be6..e4f8ed08 100644 --- a/lib/tasks/tasks.rake +++ b/lib/tasks/tasks.rake @@ -144,10 +144,7 @@ namespace :crucible do end def resolve_fhir_version(version_string) - fhir_version = :r4 - fhir_version = :stu3 if version_string.to_s.downcase == 'stu3' - fhir_version = :dstu2 if version_string.to_s.downcase == 'dstu2' - fhir_version + Crucible::FHIRVersion.resolve(version_string) end def execute_test(url, client, key, resourceType=nil, output=nil) diff --git a/test/unit/fhir_version_test.rb b/test/unit/fhir_version_test.rb new file mode 100644 index 00000000..195b416c --- /dev/null +++ b/test/unit/fhir_version_test.rb @@ -0,0 +1,42 @@ +require_relative '../test_helper' + +class FHIRVersionTest < Test::Unit::TestCase + def test_omitted_version_defaults_to_r4 + assert_equal :r4, Crucible::FHIRVersion.resolve + assert_equal :r4, Crucible::FHIRVersion.resolve('') + assert_equal :r4, Crucible::FHIRVersion.resolve(' ') + end + + def test_known_versions_are_resolved_explicitly + assert_equal :dstu2, Crucible::FHIRVersion.resolve(:dstu2) + assert_equal :stu3, Crucible::FHIRVersion.resolve('STU3') + assert_equal :r4, Crucible::FHIRVersion.resolve(:r4) + assert_equal :r4b, Crucible::FHIRVersion.resolve('R4B') + end + + def test_known_versions_are_listed_in_one_registry + assert_equal [:dstu2, :stu3, :r4, :r4b], Crucible::FHIRVersion::KNOWN + end + + def test_known_versions_resolve_to_explicit_model_namespaces + assert_same FHIR::DSTU2, Crucible::FHIRVersion.namespace(:dstu2) + assert_same FHIR::STU3, Crucible::FHIRVersion.namespace(:stu3) + assert_same FHIR, Crucible::FHIRVersion.namespace(:r4) + assert_same FHIR::R4B, Crucible::FHIRVersion.namespace(:r4b) + end + + def test_unknown_version_fails_instead_of_falling_back_to_r4 + error = assert_raise(Crucible::FHIRVersion::UnsupportedVersionError) do + Crucible::FHIRVersion.resolve('r5') + end + + assert_match(/Unsupported FHIR version 'r5'/, error.message) + assert_match(/dstu2, stu3, r4, r4b/, error.message) + end + + def test_unknown_fhir_4_version_fails_instead_of_falling_back_to_r4 + assert_raise(Crucible::FHIRVersion::UnsupportedVersionError) do + Crucible::FHIRVersion.resolve('4.1.0') + end + end +end From 2e7a7595a6e0f0c416c64b2de4bfe68453fd94fb Mon Sep 17 00:00:00 2001 From: Kenneth Myhra Date: Fri, 17 Jul 2026 17:10:50 +0200 Subject: [PATCH 02/16] Route harness resources by FHIR version --- lib/data/resources.rb | 4 +- lib/ext/client.rb | 38 +----------------- lib/fhir_version.rb | 16 +++++++- lib/resource_generator.rb | 4 +- lib/tests/base_test.rb | 9 +---- lib/tests/suites/base_suite.rb | 47 +++++------------------ lib/tests/suites/suite_engine.rb | 2 +- test/unit/fhir_version_test.rb | 7 ++++ test/unit/r4b_routing_test.rb | 66 ++++++++++++++++++++++++++++++++ 9 files changed, 104 insertions(+), 89 deletions(-) create mode 100644 test/unit/r4b_routing_test.rb diff --git a/lib/data/resources.rb b/lib/data/resources.rb index 0ff889e1..e74b7628 100644 --- a/lib/data/resources.rb +++ b/lib/data/resources.rb @@ -6,9 +6,7 @@ class Resources def initialize(fhir_version = nil) @fhir_version = fhir_version - @namespace = FHIR - @namespace = FHIR::DSTU2 if @fhir_version == :dstu2 - @namespace = FHIR::STU3 if @fhir_version == :stu3 + @namespace = Crucible::FHIRVersion.namespace(@fhir_version) end def example_patient diff --git a/lib/ext/client.rb b/lib/ext/client.rb index 8597b7f7..cd5f4d04 100644 --- a/lib/ext/client.rb +++ b/lib/ext/client.rb @@ -9,12 +9,6 @@ def record_requests(reply) @requests << reply end - def use_fhir_version(fhir_version) - self.use_r4 - self.use_stu3 if fhir_version == :stu3 - self.use_dstu2 if fhir_version == :dstu2 - end - def monitor_requests return if @decorated @decorated = true @@ -53,37 +47,7 @@ def set_client_secrets(options) end def capability_statement_new(format = @default_format) - if !@cached_capability_statement.nil? && format == @default_format - return @cached_capability_statement - end - - formats = [FHIR::Formats::ResourceFormat::RESOURCE_XML, - FHIR::Formats::ResourceFormat::RESOURCE_JSON, - FHIR::Formats::ResourceFormat::RESOURCE_XML_DSTU2, - FHIR::Formats::ResourceFormat::RESOURCE_JSON_DSTU2, - 'application/xml', - 'application/json'] - formats.insert(0, format) - - @cached_capability_statement = nil - - formats.each do |frmt| - reply = get 'metadata', fhir_headers({ accept: "#{frmt}" }) - next unless reply.code == 200 - begin - @cached_capability_statement = parse_reply(FHIR::DSTU2::Conformance, frmt, reply) if @fhir_version == :dstu2 - @cached_capability_statement = parse_reply(FHIR::STU3::CapabilityStatement, frmt, reply) if @fhir_version == :stu3 - @cached_capability_statement = parse_reply(FHIR::CapabilityStatement, frmt, reply) if @fhir_version != :dstu2 && @fhir_version != :stu3 - rescue - @cached_capability_statement = nil - end - if @cached_capability_statement - @default_format = frmt - break - end - end - @default_format = format if @default_format.nil? - @cached_capability_statement + capability_statement(format) end def fhir_patch(klass, id, patchset, options = {}, format = nil, additional_header = {}) diff --git a/lib/fhir_version.rb b/lib/fhir_version.rb index 6646e19c..57631d48 100644 --- a/lib/fhir_version.rb +++ b/lib/fhir_version.rb @@ -23,9 +23,23 @@ def self.resolve(value = nil) end def self.namespace(value = nil) - NAMESPACES.fetch(resolve(value)).split('::').inject(Object) do |parent, name| + namespace_name(value).split('::').inject(Object) do |parent, name| parent.const_get(name) end end + + def self.namespace_name(value = nil) + NAMESPACES.fetch(resolve(value)) + end + + def self.for_class(value) + class_name = value.is_a?(Module) ? value.name : value.class.name + version = NAMESPACES.sort_by { |_key, name| -name.length }.find do |_key, name| + class_name == name || class_name.start_with?("#{name}::") + end + return version.first if version + + raise UnsupportedVersionError, "Unable to determine FHIR version for #{class_name}" + end end end diff --git a/lib/resource_generator.rb b/lib/resource_generator.rb index 0b3fac5c..60557817 100644 --- a/lib/resource_generator.rb +++ b/lib/resource_generator.rb @@ -14,9 +14,7 @@ class ResourceGenerator # def self.generate(klass,embedded=0) resource = klass.new - namespace = 'FHIR' - namespace = 'FHIR::DSTU2' if klass.name.starts_with? 'FHIR::DSTU2' - namespace = 'FHIR::STU3' if klass.name.starts_with? 'FHIR::STU3' + namespace = Crucible::FHIRVersion.namespace_name(Crucible::FHIRVersion.for_class(klass)) Time.zone = 'UTC' set_fields!(resource, namespace, embedded) resource.id=nil if resource.respond_to?(:id=) diff --git a/lib/tests/base_test.rb b/lib/tests/base_test.rb index 2954a5cd..57cc016d 100644 --- a/lib/tests/base_test.rb +++ b/lib/tests/base_test.rb @@ -38,6 +38,7 @@ def initialize(client, client2=nil) FHIR::Resource.new.client = client FHIR::DSTU2::Resource.new.client = client FHIR::STU3::Resource.new.client = client + FHIR::R4B::Resource.new.client = client @client2 = client2 @client.monitor_requests if @client @client2.monitor_requests if @client2 @@ -50,13 +51,7 @@ def initialize(client, client2=nil) end def version_namespace - if @client&.fhir_version.to_s.upcase == 'DSTU2' - "FHIR::DSTU2".constantize - elsif @client&.fhir_version.to_s.upcase == 'STU3' - "FHIR::STU3".constantize - else - "FHIR".constantize - end + Crucible::FHIRVersion.namespace(@client&.fhir_version) end def multiserver diff --git a/lib/tests/suites/base_suite.rb b/lib/tests/suites/base_suite.rb index 60071fb9..3e2d9f56 100644 --- a/lib/tests/suites/base_suite.rb +++ b/lib/tests/suites/base_suite.rb @@ -12,8 +12,8 @@ def parse_operation_outcome(body) # body should be a String outcome = nil begin - outcome = FHIR.from_contents(body) - outcome = nil if outcome.class!=FHIR::OperationOutcome + outcome = version_namespace.from_contents(body) + outcome = nil unless outcome.is_a?(version_namespace.const_get(:OperationOutcome)) rescue outcome = nil end @@ -48,45 +48,23 @@ def fhir_resources end def resource_from_contents(body) - if @client.fhir_version.to_s.upcase == 'DSTU2' - FHIR::DSTU2.from_contents(body) - else - FHIR.from_contents(body) - end + version_namespace.from_contents(body) end def self.get_resource(fhir_version, resource) - if fhir_version.to_s.upcase == 'DSTU2' - "FHIR::DSTU2::#{resource}".constantize - elsif fhir_version.to_s.upcase == 'STU3' - "FHIR::STU3::#{resource}".constantize - else - "FHIR::#{resource}".constantize - end - + Crucible::FHIRVersion.namespace(fhir_version).const_get(resource) end def self.valid_resource?(fhir_version, resource) - if fhir_version.to_s.upcase == 'DSTU2' - FHIR::DSTU2::RESOURCES.include?(resource) - elsif fhir_version.to_s.upcase == 'STU3' - FHIR::STU3::RESOURCES.include?(resource) - else - FHIR::RESOURCES.include?(resource) - end + Crucible::FHIRVersion.namespace(fhir_version).const_get(:RESOURCES).include?(resource.to_s) end def self.fhir_resources(fhir_version=nil) - - resources = FHIR::RESOURCES - namespace = 'FHIR' - if !fhir_version.nil? && FHIR.constants.include?(fhir_version.upcase) - resources = FHIR.const_get(fhir_version.upcase)::RESOURCES - namespace = "FHIR::#{fhir_version.to_s.upcase}" - end - - resources.select {|r| !EXCLUDED_RESOURCES.include?(r)}.map {|r| "#{namespace}::#{r}".constantize} + namespace = Crucible::FHIRVersion.namespace(fhir_version) + namespace.const_get(:RESOURCES) + .reject { |resource| EXCLUDED_RESOURCES.include?(resource) } + .map { |resource| namespace.const_get(resource) } end def requires(hash) @@ -163,12 +141,7 @@ def self.test(key, desc, &block) def resource_category(resource) unless @resource_category @categories_by_resource = {} - fhir_version = :r4 - if resource.name.start_with? 'FHIR::DSTU2' - fhir_version = :dstu2 - elsif resource.name.start_with? 'FHIR::STU3' - fhir_version = :stu3 - end + fhir_version = Crucible::FHIRVersion.for_class(resource) fhir_structure = Crucible::FHIRStructure.get(fhir_version) categories = fhir_structure['children'].select {|n| n['name'] == 'RESOURCES'}.first['children'] pull_children = lambda {|n, chain| n['children'].nil? ? n['name'] : n['children'].map {|child| chain.call(child, chain)}} diff --git a/lib/tests/suites/suite_engine.rb b/lib/tests/suites/suite_engine.rb index e58dd027..f60a3b3b 100644 --- a/lib/tests/suites/suite_engine.rb +++ b/lib/tests/suites/suite_engine.rb @@ -27,7 +27,7 @@ def self.list_all(metadata=false) test_class = test.class.name.demodulize #if t can set class if test.respond_to? 'resource_class=' - [:dstu2, :stu3, :r4].each do |fhir_version| + (Crucible::FHIRVersion::KNOWN & test.supported_versions).each do |fhir_version| Crucible::Tests::BaseSuite.fhir_resources(fhir_version).each do |klass| klass_name = klass.name.demodulize test_name = "#{test_class}#{klass_name}" diff --git a/test/unit/fhir_version_test.rb b/test/unit/fhir_version_test.rb index 195b416c..6aa8ee7b 100644 --- a/test/unit/fhir_version_test.rb +++ b/test/unit/fhir_version_test.rb @@ -25,6 +25,13 @@ def test_known_versions_resolve_to_explicit_model_namespaces assert_same FHIR::R4B, Crucible::FHIRVersion.namespace(:r4b) end + def test_model_classes_resolve_to_their_owning_version + assert_equal :dstu2, Crucible::FHIRVersion.for_class(FHIR::DSTU2::Patient) + assert_equal :stu3, Crucible::FHIRVersion.for_class(FHIR::STU3::Patient) + assert_equal :r4, Crucible::FHIRVersion.for_class(FHIR::Patient) + assert_equal :r4b, Crucible::FHIRVersion.for_class(FHIR::R4B::Patient) + end + def test_unknown_version_fails_instead_of_falling_back_to_r4 error = assert_raise(Crucible::FHIRVersion::UnsupportedVersionError) do Crucible::FHIRVersion.resolve('r5') diff --git a/test/unit/r4b_routing_test.rb b/test/unit/r4b_routing_test.rb new file mode 100644 index 00000000..639ca1e2 --- /dev/null +++ b/test/unit/r4b_routing_test.rb @@ -0,0 +1,66 @@ +require_relative '../test_helper' + +class R4BRoutingTest < Test::Unit::TestCase + def setup + @client = FHIR::Client.new('http://r4b') + @client.use_fhir_version(:r4b) + @suite = Crucible::Tests::BaseSuite.new(@client) + end + + def test_client_extension_does_not_replace_r4b_selection + assert_equal :r4b, @client.fhir_version + end + + def test_base_test_uses_r4b_namespace + assert_same FHIR::R4B, @suite.version_namespace + end + + def test_base_suite_resolves_r4b_resources + assert_same FHIR::R4B::Citation, Crucible::Tests::BaseSuite.get_resource(:r4b, :Citation) + assert_true Crucible::Tests::BaseSuite.valid_resource?(:r4b, 'Citation') + assert_false Crucible::Tests::BaseSuite.valid_resource?(:r4, 'Citation') + end + + def test_base_suite_parses_r4b_resources + patient = @suite.resource_from_contents(FHIR::R4B::Patient.new(id: 'r4b').to_json) + outcome = @suite.parse_operation_outcome( + FHIR::R4B::OperationOutcome.new(issue: [{ severity: 'error', code: 'invalid' }]).to_json + ) + + assert_instance_of FHIR::R4B::Patient, patient + assert_instance_of FHIR::R4B::OperationOutcome, outcome + end + + def test_r4b_resource_enumeration_stays_in_r4b_namespace + resources = Crucible::Tests::BaseSuite.fhir_resources(:r4b) + + assert_not_empty resources + assert_true resources.all? { |resource| resource.name.start_with?('FHIR::R4B::') } + end + + def test_resource_generator_uses_r4b_types + patient = Crucible::Tests::ResourceGenerator.generate(FHIR::R4B::Patient) + + assert_instance_of FHIR::R4B::Patient, patient + assert_instance_of FHIR::R4B::Meta, patient.meta + end + + def test_resource_fixture_helper_uses_r4b_namespace + resources = Crucible::Generator::Resources.new(:r4b) + + assert_same FHIR::R4B, resources.instance_variable_get(:@namespace) + end + + def test_resource_suite_metadata_does_not_advertise_unaudited_r4b_support + resource_test = Crucible::Tests::ResourceTest.new(nil) + resource_suite_metadata = Crucible::Tests::SuiteEngine.list_all.values.select do |metadata| + metadata.key?('resource_class') + end + advertises_r4b = resource_suite_metadata.any? do |metadata| + metadata['supported_versions'].include?(:r4b) + end + + assert_not_include resource_test.supported_versions, :r4b + assert_false advertises_r4b + end +end From d70c01c264b9a8e94f0e75427cf8c7ed7ad5fe76 Mon Sep 17 00:00:00 2001 From: Kenneth Myhra Date: Fri, 17 Jul 2026 17:19:05 +0200 Subject: [PATCH 03/16] Generate the FHIR R4B structure index --- lib/FHIR_structure_r4b.json | 936 +++++++++++++++++++++ lib/data/fhir_structure_generator.rb | 93 ++ lib/tasks/fhir_structure.rake | 15 + test/unit/fhir_structure_generator_test.rb | 89 ++ test/unit/fhir_structure_test.rb | 25 +- 5 files changed, 1145 insertions(+), 13 deletions(-) create mode 100644 lib/FHIR_structure_r4b.json create mode 100644 lib/data/fhir_structure_generator.rb create mode 100644 lib/tasks/fhir_structure.rake create mode 100644 test/unit/fhir_structure_generator_test.rb diff --git a/lib/FHIR_structure_r4b.json b/lib/FHIR_structure_r4b.json new file mode 100644 index 00000000..242e1ac7 --- /dev/null +++ b/lib/FHIR_structure_r4b.json @@ -0,0 +1,936 @@ +{ + "name": "FHIR", + "children": [ + { + "name": "OPERATIONS", + "children": [ + { + "name": "RESTful API", + "children": [ + { + "name": "Instance Level Interactions", + "children": [ + { + "name": "read" + }, + { + "name": "vread" + }, + { + "name": "update" + }, + { + "name": "conditional-update" + }, + { + "name": "delete" + }, + { + "name": "history" + }, + { + "name": "patch" + } + ] + }, + { + "name": "Type Level Interactions", + "children": [ + { + "name": "create" + }, + { + "name": "conditional-create" + }, + { + "name": "search" + }, + { + "name": "history-type" + } + ] + }, + { + "name": "Whole System Interactions", + "children": [ + { + "name": "capabilities-system" + }, + { + "name": "transaction-system" + }, + { + "name": "history-system" + }, + { + "name": "search-system" + }, + { + "name": "batch-system" + } + ] + } + ] + }, + { + "name": "Extended Operations", + "children": [ + { + "name": "$validate" + }, + { + "name": "$meta" + }, + { + "name": "$meta-add" + }, + { + "name": "$meta-delete" + }, + { + "name": "$convert" + }, + { + "name": "$graph-ql" + }, + { + "name": "$graph" + }, + { + "name": "$apply" + }, + { + "name": "$data-requirements" + }, + { + "name": "$subset" + }, + { + "name": "$implements" + }, + { + "name": "$conforms" + }, + { + "name": "$versions" + }, + { + "name": "$submit" + }, + { + "name": "$submit-data" + }, + { + "name": "$collect-data" + }, + { + "name": "$care-gaps" + }, + { + "name": "$preferred-id" + }, + { + "name": "$stats" + }, + { + "name": "$lastn" + }, + { + "name": "$transform" + }, + { + "name": "$snapshot" + }, + { + "name": "$subsumes" + }, + { + "name": "$find-matches" + }, + { + "name": "$document" + }, + { + "name": "$translate" + }, + { + "name": "$closure" + }, + { + "name": "$everything" + }, + { + "name": "$find" + }, + { + "name": "$process-message" + }, + { + "name": "$populate" + }, + { + "name": "$questionnaire" + }, + { + "name": "$expand" + }, + { + "name": "$lookup" + }, + { + "name": "$validate-code" + }, + { + "name": "$guidance" + }, + { + "name": "$guidance-requirements" + }, + { + "name": "$match" + } + ] + } + ] + }, + { + "name": "RESOURCES", + "children": [ + { + "name": "Foundation", + "children": [ + { + "name": "Conformance", + "children": [ + { + "name": "capability statement" + }, + { + "name": "compartment definition" + }, + { + "name": "example scenario" + }, + { + "name": "graph definition" + }, + { + "name": "implementation guide" + }, + { + "name": "message definition" + }, + { + "name": "operation definition" + }, + { + "name": "search parameter" + }, + { + "name": "structure definition" + }, + { + "name": "structure map" + } + ] + }, + { + "name": "Terminology", + "children": [ + { + "name": "code system" + }, + { + "name": "concept map" + }, + { + "name": "naming system" + }, + { + "name": "terminology capabilities" + }, + { + "name": "value set" + } + ] + }, + { + "name": "Security", + "aka": [ + "Security & Privacy" + ], + "children": [ + { + "name": "audit event" + }, + { + "name": "consent" + }, + { + "name": "provenance" + } + ] + }, + { + "name": "Documents", + "aka": [ + "Documents & Questionnaires" + ], + "children": [ + { + "name": "catalog entry" + }, + { + "name": "composition" + }, + { + "name": "document manifest" + }, + { + "name": "document reference" + } + ] + }, + { + "name": "Other", + "aka": [ + "Exchange", + "Structure", + "Structures" + ], + "children": [ + { + "name": "basic" + }, + { + "name": "binary" + }, + { + "name": "bundle" + }, + { + "name": "linkage" + }, + { + "name": "message header" + }, + { + "name": "operation outcome" + }, + { + "name": "parameters" + }, + { + "name": "subscription" + }, + { + "name": "subscription status" + }, + { + "name": "subscription topic" + } + ] + } + ] + }, + { + "name": "Base", + "aka": [ + "Administration", + "Administrative", + "Identification" + ], + "children": [ + { + "name": "Individuals", + "children": [ + { + "name": "group" + }, + { + "name": "patient" + }, + { + "name": "person" + }, + { + "name": "practitioner" + }, + { + "name": "practitioner role" + }, + { + "name": "related person" + } + ] + }, + { + "name": "Entities", + "children": [ + { + "name": "biologically derived product" + }, + { + "name": "device" + }, + { + "name": "device metric" + }, + { + "name": "endpoint" + }, + { + "name": "healthcare service" + }, + { + "name": "location" + }, + { + "name": "nutrition product" + }, + { + "name": "organization" + }, + { + "name": "organization affiliation" + }, + { + "name": "substance" + } + ] + }, + { + "name": "Workflow", + "aka": [ + "Scheduling", + "Events", + "Order Management" + ], + "children": [ + { + "name": "appointment" + }, + { + "name": "appointment response" + }, + { + "name": "schedule" + }, + { + "name": "slot" + }, + { + "name": "task" + }, + { + "name": "verification result" + } + ] + }, + { + "name": "Management", + "aka": [ + "Patient Management" + ], + "children": [ + { + "name": "encounter" + }, + { + "name": "episode of care" + }, + { + "name": "flag" + }, + { + "name": "library" + }, + { + "name": "list" + } + ] + } + ] + }, + { + "name": "Clinical", + "children": [ + { + "name": "Summary", + "aka": [ + "General Clinical" + ], + "children": [ + { + "name": "adverse event" + }, + { + "name": "allergy intolerance" + }, + { + "name": "clinical impression" + }, + { + "name": "condition" + }, + { + "name": "detected issue" + }, + { + "name": "family member history" + }, + { + "name": "procedure" + } + ] + }, + { + "name": "Diagnostics", + "children": [ + { + "name": "body structure" + }, + { + "name": "diagnostic report" + }, + { + "name": "imaging study" + }, + { + "name": "media" + }, + { + "name": "molecular sequence" + }, + { + "name": "observation" + }, + { + "name": "questionnaire response" + }, + { + "name": "specimen" + } + ] + }, + { + "name": "Medications", + "aka": [ + "Medication & Immunization" + ], + "children": [ + { + "name": "immunization" + }, + { + "name": "immunization evaluation" + }, + { + "name": "immunization recommendation" + }, + { + "name": "medication" + }, + { + "name": "medication administration" + }, + { + "name": "medication dispense" + }, + { + "name": "medication knowledge" + }, + { + "name": "medication request" + }, + { + "name": "medication statement" + } + ] + }, + { + "name": "Care Provision", + "children": [ + { + "name": "care plan" + }, + { + "name": "care team" + }, + { + "name": "goal" + }, + { + "name": "nutrition order" + }, + { + "name": "request group" + }, + { + "name": "risk assessment" + }, + { + "name": "service request" + }, + { + "name": "vision prescription" + } + ] + }, + { + "name": "Request & Response", + "children": [ + { + "name": "communication" + }, + { + "name": "communication request" + }, + { + "name": "device request" + }, + { + "name": "device use statement" + }, + { + "name": "guidance response" + }, + { + "name": "supply delivery" + }, + { + "name": "supply request" + } + ] + } + ] + }, + { + "name": "Financial", + "children": [ + { + "name": "Support", + "children": [ + { + "name": "coverage" + }, + { + "name": "coverage eligibility request" + }, + { + "name": "coverage eligibility response" + }, + { + "name": "enrollment request" + }, + { + "name": "enrollment response" + } + ] + }, + { + "name": "Billing", + "children": [ + { + "name": "claim" + }, + { + "name": "claim response" + }, + { + "name": "invoice" + } + ] + }, + { + "name": "Payment", + "children": [ + { + "name": "payment notice" + }, + { + "name": "payment reconciliation" + } + ] + }, + { + "name": "General", + "aka": [ + "Other" + ], + "children": [ + { + "name": "account" + }, + { + "name": "charge item" + }, + { + "name": "charge item definition" + }, + { + "name": "contract" + }, + { + "name": "explanation of benefit" + }, + { + "name": "insurance plan" + } + ] + } + ] + }, + { + "name": "Specialized", + "children": [ + { + "name": "Public Health & Research", + "aka": [ + "Research" + ], + "children": [ + { + "name": "research study" + }, + { + "name": "research subject" + } + ] + }, + { + "name": "Definitional Artifacts", + "children": [ + { + "name": "activity definition" + }, + { + "name": "device definition" + }, + { + "name": "event definition" + }, + { + "name": "observation definition" + }, + { + "name": "plan definition" + }, + { + "name": "questionnaire" + }, + { + "name": "specimen definition" + } + ] + }, + { + "name": "Evidence-Based Medicine", + "children": [ + { + "name": "citation" + }, + { + "name": "evidence" + }, + { + "name": "evidence report" + }, + { + "name": "evidence variable" + }, + { + "name": "research definition" + }, + { + "name": "research element definition" + } + ] + }, + { + "name": "Quality Reporting & Testing", + "aka": [ + "Clinical Reasoning", + "Quality Reporting" + ], + "children": [ + { + "name": "measure" + }, + { + "name": "measure report" + }, + { + "name": "test report" + }, + { + "name": "test script" + } + ] + }, + { + "name": "Medication Definition", + "children": [ + { + "name": "administrable product definition" + }, + { + "name": "clinical use definition" + }, + { + "name": "ingredient" + }, + { + "name": "manufactured item definition" + }, + { + "name": "medicinal product definition" + }, + { + "name": "packaged product definition" + }, + { + "name": "regulated authorization" + }, + { + "name": "substance definition" + } + ] + } + ] + } + ] + }, + { + "name": "FORMAT", + "children": [ + { + "name": "XML" + }, + { + "name": "JSON" + } + ] + }, + { + "name": "SECURITY", + "children": [ + { + "name": "General Security", + "children": [ + { + "name": "Authorization/Access Control" + }, + { + "name": "OAuth2" + }, + { + "name": "Audit Logging" + }, + { + "name": "Digital Signatures" + } + ] + }, + { + "name": "Security Labels", + "children": [ + { + "name": "Confidentiality Codes" + }, + { + "name": "Celebrity / VIP" + }, + { + "name": "Staff" + }, + { + "name": "Keep information from patient" + }, + { + "name": "Contact/Employment Details Confidential" + }, + { + "name": "Diagnosis-related confidentiality" + }, + { + "name": "Author consent needed" + }, + { + "name": "Delete After Use" + }, + { + "name": "Do Not Reuse" + }, + { + "name": "Break The Glass" + }, + { + "name": "Confidentiality Classification" + }, + { + "name": "Sensitivity Category" + }, + { + "name": "Compartment Category" + }, + { + "name": "Integrity Category" + }, + { + "name": "Handling Caveat" + }, + { + "name": "US Privacy Law" + } + ] + } + ] + }, + { + "name": "MESSAGING", + "children": [ + { + "name": "Consequence" + }, + { + "name": "Currency" + }, + { + "name": "Notification" + } + ] + }, + { + "name": "DOCUMENTS", + "children": [ + + ] + }, + { + "name": "PROFILES", + "children": [ + { + "name": "validate-profile" + } + ] + }, + { + "name": "EXTENSIONS", + "children": [ + { + "name": "extensions" + }, + { + "name": "modifying extensions" + }, + { + "name": "complex extensions" + }, + { + "name": "primitive extensions" + } + ] + } + ] +} diff --git a/lib/data/fhir_structure_generator.rb b/lib/data/fhir_structure_generator.rb new file mode 100644 index 00000000..146eaf01 --- /dev/null +++ b/lib/data/fhir_structure_generator.rb @@ -0,0 +1,93 @@ +require 'cgi' +require 'digest' +require 'open3' + +module Crucible + class FHIRStructureGenerator + DEFINITIONS_URL = 'https://www.hl7.org/fhir/R4B/definitions.json.zip' + DEFINITIONS_SHA256 = 'a2793a06853c2d4540db8a72fc1c6d972528b01d113c2bb70ae2d80dc062e963' + PROFILES_ENTRY = 'definitions.json/profiles-resources.json' + CATEGORY_URL = 'http://hl7.org/fhir/StructureDefinition/structuredefinition-category' + # These two base definitions omit the category extension in the official archive. + CATEGORY_OVERRIDES = { + 'ResearchDefinition' => 'Specialized.Evidence-Based Medicine', + 'ResearchElementDefinition' => 'Specialized.Evidence-Based Medicine' + }.freeze + + def self.from_archive(archive_path, template_path) + checksum = Digest::SHA256.file(archive_path).hexdigest + raise "Unexpected R4B definitions checksum: #{checksum}" unless checksum == DEFINITIONS_SHA256 + + profiles_json, status = Open3.capture2('unzip', '-p', archive_path, PROFILES_ENTRY) + raise "Unable to read #{PROFILES_ENTRY} from #{archive_path}" unless status.success? + + generate(JSON.parse(profiles_json), JSON.parse(File.read(template_path))) + end + + def self.generate(structure_definitions, template) + result = JSON.parse(JSON.generate(template)) + resource_root = result.fetch('children').find { |child| child['name'] == 'RESOURCES' } + raise 'FHIR structure template has no RESOURCES branch' unless resource_root + + categories = reset_resource_categories(resource_root) + concrete_resources(structure_definitions).each do |resource| + category_path = resource_category(resource) + category = categories[category_path] || add_category(resource_root, categories, category_path) + category.fetch('children') << { 'name' => humanize(resource.fetch('name')) } + end + result + end + + def self.write_from_archive(archive_path, template_path, output_path) + structure = from_archive(archive_path, template_path) + File.write(output_path, "#{JSON.pretty_generate(structure)}\n") + end + + def self.reset_resource_categories(resource_root) + resource_root.fetch('children').each_with_object({}) do |section, categories| + section.fetch('children').each do |category| + category['children'] = [] + categories["#{section.fetch('name')}.#{category.fetch('name')}"] = category + end + end + end + private_class_method :reset_resource_categories + + def self.concrete_resources(structure_definitions) + structure_definitions.fetch('entry').map { |entry| entry.fetch('resource') } + .select { |resource| resource['kind'] == 'resource' && resource['derivation'] == 'specialization' } + .reject { |resource| resource['abstract'] == true } + end + private_class_method :concrete_resources + + def self.resource_category(resource) + extension = resource.fetch('extension', []).find { |item| item['url'] == CATEGORY_URL } + category = extension && extension['valueString'] + category ||= CATEGORY_OVERRIDES[resource.fetch('name')] + raise "No R4B resource category for #{resource.fetch('name')}" unless category + + CGI.unescapeHTML(category) + end + private_class_method :resource_category + + def self.add_category(resource_root, categories, category_path) + section_name, category_name = category_path.split('.', 2) + raise "Invalid R4B resource category: #{category_path}" unless category_name + + section = resource_root.fetch('children').find { |child| child['name'] == section_name } + unless section + section = { 'name' => section_name, 'children' => [] } + resource_root.fetch('children') << section + end + category = { 'name' => category_name, 'children' => [] } + section.fetch('children') << category + categories[category_path] = category + end + private_class_method :add_category + + def self.humanize(name) + name.gsub(/([a-z\d])([A-Z])/, '\\1 \\2').downcase + end + private_class_method :humanize + end +end diff --git a/lib/tasks/fhir_structure.rake b/lib/tasks/fhir_structure.rake new file mode 100644 index 00000000..c5c9e7ce --- /dev/null +++ b/lib/tasks/fhir_structure.rake @@ -0,0 +1,15 @@ +namespace :crucible do + desc 'Generate the R4B FHIR structure index from the official definitions archive' + task :generate_r4b_structure, [:definitions_archive] do |_task, args| + unless args.definitions_archive + raise 'Usage: rake "crucible:generate_r4b_structure[path/to/r4b-definitions.json.zip]"' + end + + root = File.expand_path('../..', __dir__) + Crucible::FHIRStructureGenerator.write_from_archive( + File.expand_path(args.definitions_archive), + File.join(root, 'lib', 'FHIR_structure_r4.json'), + File.join(root, 'lib', 'FHIR_structure_r4b.json') + ) + end +end diff --git a/test/unit/fhir_structure_generator_test.rb b/test/unit/fhir_structure_generator_test.rb new file mode 100644 index 00000000..06354b3c --- /dev/null +++ b/test/unit/fhir_structure_generator_test.rb @@ -0,0 +1,89 @@ +require_relative '../test_helper' + +class FHIRStructureGeneratorTest < Test::Unit::TestCase + def test_generates_resource_categories_from_structure_definitions + structure = Crucible::FHIRStructureGenerator.generate(definitions, template) + resources = structure.fetch('children').find { |child| child['name'] == 'RESOURCES' } + evidence = resources.fetch('children').first.fetch('children').first + + assert_equal 'Evidence-Based Medicine', evidence['name'] + assert_equal ['citation', 'research definition'], evidence.fetch('children').map { |child| child['name'] } + end + + def test_does_not_modify_the_template + original = JSON.generate(template) + + Crucible::FHIRStructureGenerator.generate(definitions, template) + + assert_equal original, JSON.generate(template) + end + + def test_rejects_uncategorized_resources_without_an_explicit_override + uncategorized = definitions + uncategorized['entry'].first['resource']['extension'] = [] + + assert_raise(RuntimeError) do + Crucible::FHIRStructureGenerator.generate(uncategorized, template) + end + end + + private + + def template + { + 'name' => 'FHIR', + 'children' => [ + { + 'name' => 'RESOURCES', + 'children' => [ + { + 'name' => 'Specialized', + 'children' => [ + { 'name' => 'Evidence-Based Medicine', 'children' => [{ 'name' => 'old resource' }] } + ] + } + ] + } + ] + } + end + + def definitions + { + 'entry' => [ + { + 'resource' => { + 'name' => 'Citation', + 'kind' => 'resource', + 'derivation' => 'specialization', + 'abstract' => false, + 'extension' => [ + { + 'url' => Crucible::FHIRStructureGenerator::CATEGORY_URL, + 'valueString' => 'Specialized.Evidence-Based Medicine' + } + ] + } + }, + { + 'resource' => { + 'name' => 'ResearchDefinition', + 'kind' => 'resource', + 'derivation' => 'specialization', + 'abstract' => false, + 'extension' => [] + } + }, + { + 'resource' => { + 'name' => 'Resource', + 'kind' => 'resource', + 'derivation' => 'specialization', + 'abstract' => true, + 'extension' => [] + } + } + ] + } + end +end diff --git a/test/unit/fhir_structure_test.rb b/test/unit/fhir_structure_test.rb index 65d453ab..81bf5cf2 100644 --- a/test/unit/fhir_structure_test.rb +++ b/test/unit/fhir_structure_test.rb @@ -4,21 +4,26 @@ class FHIRStructureTest < Test::Unit::TestCase def test_fhir_starburst_root structure = Crucible::FHIRStructure.get(:r4) - structure['name'] == 'FHIR' + assert_equal 'FHIR', structure['name'] + end + + def test_fhir_starburst_root_r4b + structure = Crucible::FHIRStructure.get(:r4b) + assert_equal 'FHIR', structure['name'] end def test_fhir_starburst_stu3 structure = Crucible::FHIRStructure.get(:stu3) - structure['name'] == 'FHIR' + assert_equal 'FHIR', structure['name'] end def test_fhir_starburst_root_dstu2 structure = Crucible::FHIRStructure.get(:dstu2) - structure['name'] == 'FHIR' + assert_equal 'FHIR', structure['name'] end def test_no_duplicate_names_in_starburst - [:stu3, :dstu2].each do |version| + Crucible::FHIRVersion::KNOWN.each do |version| structure = Crucible::FHIRStructure.get(version) names = all_names(structure) @@ -27,18 +32,12 @@ def test_no_duplicate_names_in_starburst end def fhir_resources(fhir_version=nil) - - resources = FHIR::RESOURCES - namespace = 'FHIR' - if !fhir_version.nil? && FHIR.constants.include?(fhir_version.upcase) - resources = FHIR.const_get(fhir_version.upcase)::RESOURCES - end - resources + Crucible::FHIRVersion.namespace(fhir_version).const_get(:RESOURCES) end def test_no_missing_resources_in_starburst - [:r4, :stu3, :dstu2].each do |version| + Crucible::FHIRVersion::KNOWN.each do |version| structure = Crucible::FHIRStructure.get(version) resource_subset = structure['children'].select{|c| c['name'] == 'RESOURCES'}.first structure_resources = all_names(resource_subset, true).map{|e| e.downcase.delete(' ')} @@ -61,7 +60,7 @@ def test_no_unknown_requires_in_tests names = [] - [:dstu2, :stu3, :r4].each do |version| + Crucible::FHIRVersion::KNOWN.each do |version| structure = Crucible::FHIRStructure.get(version) names.concat(all_names(structure).map{|e| e.downcase.delete(' ')}) end From 12e10e840980f822b7c78f63e472c01281386d7b Mon Sep 17 00:00:00 2001 From: Kenneth Myhra Date: Fri, 17 Jul 2026 17:20:57 +0200 Subject: [PATCH 04/16] Fix version-specific fixture lookup --- lib/data/resources.rb | 3 +- test/unit/fixture_selection_test.rb | 44 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 test/unit/fixture_selection_test.rb diff --git a/lib/data/resources.rb b/lib/data/resources.rb index e74b7628..2722c104 100644 --- a/lib/data/resources.rb +++ b/lib/data/resources.rb @@ -241,7 +241,8 @@ def tag_metadata(resource) def load_fixture(path, extension) full_path = File.join(fixture_path, "#{path}.#{extension.to_s}") - full_path = File.join(fixture_path, "#{path}.#{@fhir_version.to_s}.#{extension}") if File.exist?(File.join("#{path}.#{@fhir_version.to_s}.#{extension}")) + versioned_path = File.join(fixture_path, "#{path}.#{@fhir_version}.#{extension}") + full_path = versioned_path if File.exist?(versioned_path) tag_metadata(@namespace.from_contents(File.read(full_path))) end diff --git a/test/unit/fixture_selection_test.rb b/test/unit/fixture_selection_test.rb new file mode 100644 index 00000000..eefa2a90 --- /dev/null +++ b/test/unit/fixture_selection_test.rb @@ -0,0 +1,44 @@ +require_relative '../test_helper' +require 'tmpdir' + +class FixtureSelectionTest < Test::Unit::TestCase + def test_selects_version_specific_fixture_from_the_fixture_root + with_fixture_helper(:r4b) do |resources, directory| + write_patient(directory, 'patient.json', 'base') + write_patient(directory, 'patient.r4b.json', 'r4b') + + patient = resources.load_fixture('patient', :json) + + assert_instance_of FHIR::R4B::Patient, patient + assert_equal 'r4b', patient.id + end + end + + def test_falls_back_to_the_base_fixture + with_fixture_helper(:r4b) do |resources, directory| + write_patient(directory, 'patient.json', 'base') + + patient = resources.load_fixture('patient', :json) + + assert_instance_of FHIR::R4B::Patient, patient + assert_equal 'base', patient.id + end + end + + private + + def with_fixture_helper(version) + Dir.mktmpdir do |directory| + resources = Crucible::Generator::Resources.new(version) + resources.define_singleton_method(:fixture_path) { directory } + yield resources, directory + end + end + + def write_patient(directory, filename, id) + File.write( + File.join(directory, filename), + JSON.generate(resourceType: 'Patient', id: id) + ) + end +end From a2b1482b6d7527a796330cfb18dd6df69b9d7b49 Mon Sep 17 00:00:00 2001 From: Kenneth Myhra Date: Fri, 17 Jul 2026 17:24:24 +0200 Subject: [PATCH 05/16] Make suite version support explicit --- lib/tests/base_test.rb | 2 +- lib/tests/suites/format_test.rb | 1 + lib/tests/suites/history_test.rb | 1 + lib/tests/suites/read_test.rb | 1 + lib/tests/suites/resource_test.rb | 1 + lib/tests/suites/search_test.rb | 1 + lib/tests/suites/sprinkler_search_test.rb | 1 + lib/tests/suites/transaction_test.rb | 1 + test/unit/supported_versions_test.rb | 26 +++++++++++++++++++++++ 9 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 test/unit/supported_versions_test.rb diff --git a/lib/tests/base_test.rb b/lib/tests/base_test.rb index 57cc016d..7bb8290e 100644 --- a/lib/tests/base_test.rb +++ b/lib/tests/base_test.rb @@ -43,7 +43,7 @@ def initialize(client, client2=nil) @client.monitor_requests if @client @client2.monitor_requests if @client2 @tags ||= [] - @supported_versions ||= [:dstu2, :stu3, :r4] + @supported_versions ||= [] @warnings = [] @setup_failed = false @setup_requests = [] diff --git a/lib/tests/suites/format_test.rb b/lib/tests/suites/format_test.rb index 8e903aa0..4e2bec44 100644 --- a/lib/tests/suites/format_test.rb +++ b/lib/tests/suites/format_test.rb @@ -16,6 +16,7 @@ def description def initialize(client1, client2=nil) super(client1, client2) + @supported_versions = [:dstu2, :stu3, :r4] if fhir_version == :dstu2 @xml_format = FHIR::Formats::ResourceFormat::RESOURCE_XML_DSTU2 @json_format = FHIR::Formats::ResourceFormat::RESOURCE_JSON_DSTU2 diff --git a/lib/tests/suites/history_test.rb b/lib/tests/suites/history_test.rb index 103f117d..3fa54cb3 100644 --- a/lib/tests/suites/history_test.rb +++ b/lib/tests/suites/history_test.rb @@ -12,6 +12,7 @@ def description def initialize(client1, client2=nil) super(client1, client2) + @supported_versions = [:dstu2, :stu3, :r4] @category = {id: 'core_functionality', title: 'Core Functionality'} end diff --git a/lib/tests/suites/read_test.rb b/lib/tests/suites/read_test.rb index c6f5f942..ff812ca6 100644 --- a/lib/tests/suites/read_test.rb +++ b/lib/tests/suites/read_test.rb @@ -12,6 +12,7 @@ def description def initialize(client1, client2=nil) super(client1, client2) + @supported_versions = [:dstu2, :stu3, :r4] @category = {id: 'core_functionality', title: 'Core Functionality'} end diff --git a/lib/tests/suites/resource_test.rb b/lib/tests/suites/resource_test.rb index 545da4f2..4cb84c49 100644 --- a/lib/tests/suites/resource_test.rb +++ b/lib/tests/suites/resource_test.rb @@ -50,6 +50,7 @@ def category def initialize(client1, client2=nil) super(client1, client2) + @supported_versions = [:dstu2, :stu3, :r4] end # this allows results to have unique ids for resource based tests diff --git a/lib/tests/suites/search_test.rb b/lib/tests/suites/search_test.rb index 531c2395..0a6daa79 100644 --- a/lib/tests/suites/search_test.rb +++ b/lib/tests/suites/search_test.rb @@ -38,6 +38,7 @@ def category def initialize(client1, client2=nil) super(client1, client2) + @supported_versions = [:dstu2, :stu3, :r4] end # this allows results to have unique ids for resource based tests diff --git a/lib/tests/suites/sprinkler_search_test.rb b/lib/tests/suites/sprinkler_search_test.rb index f729aeea..23f28f4e 100644 --- a/lib/tests/suites/sprinkler_search_test.rb +++ b/lib/tests/suites/sprinkler_search_test.rb @@ -14,6 +14,7 @@ def description def initialize(client1, client2=nil) super(client1, client2) + @supported_versions = [:dstu2, :stu3, :r4] @category = {id: 'core_functionality', title: 'Core Functionality'} end diff --git a/lib/tests/suites/transaction_test.rb b/lib/tests/suites/transaction_test.rb index c78e9d19..3939a89d 100644 --- a/lib/tests/suites/transaction_test.rb +++ b/lib/tests/suites/transaction_test.rb @@ -12,6 +12,7 @@ def description def initialize(client1, client2=nil) super(client1, client2) + @supported_versions = [:dstu2, :stu3, :r4] @category = {id: 'core_functionality', title: 'Core Functionality'} end diff --git a/test/unit/supported_versions_test.rb b/test/unit/supported_versions_test.rb new file mode 100644 index 00000000..281a338a --- /dev/null +++ b/test/unit/supported_versions_test.rb @@ -0,0 +1,26 @@ +require_relative '../test_helper' + +class SupportedVersionsTest < Test::Unit::TestCase + def test_base_suite_does_not_grant_implicit_version_support + assert_empty Crucible::Tests::BaseSuite.new(nil).supported_versions + end + + def test_every_executable_suite_declares_supported_versions + suites = Crucible::Tests::SuiteEngine.new.tests + + assert_true suites.all? { |suite| suite.supported_versions.any? } + end + + def test_resource_suites_preserve_their_existing_version_support + expected = [:dstu2, :stu3, :r4] + + assert_equal expected, Crucible::Tests::ResourceTest.new(nil).supported_versions + assert_equal expected, Crucible::Tests::SearchTest.new(nil).supported_versions + end + + def test_no_suite_implicitly_advertises_r4b + suites = Crucible::Tests::SuiteEngine.new.tests + + assert_true suites.none? { |suite| suite.supported_versions.include?(:r4b) } + end +end From 45b4657eae174b36a5dfef8b48a8ee7f0b01e8a6 Mon Sep 17 00:00:00 2001 From: Kenneth Myhra Date: Fri, 17 Jul 2026 17:52:08 +0200 Subject: [PATCH 06/16] Document the R4B support architecture --- R4B.md | 247 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 22 +++-- 2 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 R4B.md diff --git a/R4B.md b/R4B.md new file mode 100644 index 00000000..1a2db746 --- /dev/null +++ b/R4B.md @@ -0,0 +1,247 @@ +# R4B Support Action Points + +## Design Rules + +- Treat `:r4` and `:r4b` as distinct harness versions. +- Never allow R4B to resolve through implicit R4 fallback. +- Shared R4/R4B behavior is allowed only through explicit compatibility annotations. +- Keep R4 models at the existing top-level `FHIR::*` namespace for backward compatibility and expose R4B models through the required `FHIR::R4B::*` namespace. +- An omitted version may continue to default to R4, but an explicitly supplied unknown version must fail fast. +- Keep version annotations explicit, even for resources that are normative or unchanged across R4 and R4B. +- Add R4B model support to `fhir_models`; do not introduce a separate R4B model gem by default. +- Keep the registry of versions understood by the harness separate from the versions supported by each test suite. + +## Related Repositories + +- `git@github.com:incendilabs/fhir_client.git`, local path `../fhir_client` +- `git@github.com:incendilabs/fhir_models.git`, local path `../fhir_models` +- `git@github.com:incendilabs/fhir_dstu2_models.git`, local path `../fhir_dstu2_models` +- `git@github.com:incendilabs/fhir_stu3_models.git`, local path `../fhir_stu3_models` + +R4B feature work is expected in `fhir_models`, `fhir_client`, and this repository. The DSTU2 and STU3 model repositories remain in scope as regression dependencies, but no R4B feature changes are expected in them. + +## Implementation Status + +- `fhir_models` now has namespace-aware infrastructure, generated + `FHIR::R4B` models, checked-in R4B runtime definitions, documented generation, + and R4B XML schema validation. These changes are split across commits + `cf7f5d5a`, `03059207`, `90cd55fd`, `67738dfe`, and `444f74f7`. +- `fhir_client` now has explicit R4B routing and CapabilityStatement version + detection in commit `7bde8ed2`. +- `plan-executor` now has a central version registry, version-aware resource + routing, a generated R4B structure index, corrected version-specific fixture + lookup, and explicit suite compatibility annotations. These changes are split + across commits `17537c9a`, `2e7a759`, `d70c01c`, `12e10e8`, and `a2b1482`. +- No executable suite currently declares R4B compatibility. Suites must be + audited individually before `:r4b` is added to their annotations. +- Remaining integration work consists of publishing or pinning compatible + `fhir_models` and `fhir_client` revisions, updating dependency resolution, + auditing candidate suites, and running endpoint smoke tests and the final + cross-version regression matrix. + +## Baseline And Dependency Actions + +- Reconcile the local `../fhir_models` checkout with the version currently resolved by this repository before implementing R4B. The local checkout identifies itself as gem version `4.1.0` with FHIR `4.0.1` definitions, while this repository currently locks released `fhir_models` version `4.3.0`. +- Do not confuse the `fhir_models` gem version with the FHIR specification version. Record both independently. +- Pin the official FHIR R4B `4.3.0` JSON definitions archive, JSON ValueSet + expansion Bundle, and XML schema archive used for generation. Generate models + and runtime definitions with + `bundle exec rake "fhir:generate_r4b[path/to/r4b-definitions.json.zip,path/to/expansions.json]"`. + Generate the XML schema set with + `bundle exec rake "fhir:generate_r4b_schema[path/to/r4b-fhir-all-xsd.zip]"`. + Download the artifacts from the official HL7 + [`definitions.json.zip`](https://www.hl7.org/fhir/R4B/definitions.json.zip) and + [`expansions.json`](https://www.hl7.org/fhir/R4B/expansions.json) endpoints, and + the [`fhir-all-xsd.zip`](https://www.hl7.org/fhir/R4B/fhir-all-xsd.zip) + endpoint. + The pinned SHA-256 values are + `a2793a06853c2d4540db8a72fc1c6d972528b01d113c2bb70ae2d80dc062e963` + for the definitions archive and + `fe10ca33f0de85c16b367cb57092076d7e2fbd7aff6479c8862a32bd227e3b07` + for the expansion Bundle, and + `3528d4ff44c69f2908d6159367d58b9d12fb41a48d1be3ec897947129696e6b4` + for the XML schema archive. +- Define the local cross-repository development setup, using temporary `path:` dependencies or equivalent local wiring so changes in `../fhir_models` and `../fhir_client` are exercised by this repository. +- Define the release and dependency update order: `fhir_models`, then `fhir_client`, then `plan-executor`. +- Update gem version constraints and `Gemfile.lock` to released versions or immutable commit references before final integration. + +## Model Definition Architecture + +### R4 Baseline + +- R4 runtime definitions remain under `lib/fhir_models/definitions/` in + `fhir_models`. +- R4 stores separate preprocessed files for StructureDefinitions, ValueSets, + expansions, XML schemas, and version metadata. +- `FHIR::Definitions` reads the individual JSON files directly. R4 XML + validation reads the XSD files under `lib/fhir_models/definitions/schema/`. +- The generated R4 Ruby models remain separate under `lib/fhir_models/fhir/`. +- Preserve this layout and the existing top-level `FHIR::Definitions` API for + backward compatibility. + +### R4B Runtime Definitions + +- Generated R4B Ruby models and metadata are stored under + `lib/fhir_models/r4b/`. +- R4B runtime definitions follow the established R4 directory pattern under + `lib/fhir_models/definitions/r4b/`, with separate `structures/`, + `valuesets/`, and `schema/` directories plus `version.info`. +- `FHIR::Definitions` and `FHIR::R4B::Definitions` use the same configurable, + directory-backed provider. R4 remains configured against + `lib/fhir_models/definitions/`, while R4B is configured against + `lib/fhir_models/definitions/r4b/` and constructs R4B model objects. +- Definition bundles are parsed lazily and cached in memory. The R4B provider + verifies `version.info` against FHIR version `4.3.0` before loading them. +- StructureDefinition objects returned by the provider must be + `FHIR::R4B::StructureDefinition` instances. Model binding, reference, and + StructureDefinition validation must select definitions from the owning model + namespace and must never fall back implicitly from R4B to R4. +- The checked-in generated files contain datatype and resource + StructureDefinitions, profiles, extensions, search parameters, ValueSets, + expansions, and version metadata. +- The shared provider preserves the existing R4 Definitions API for both + versions, including raw `valuesets`, raw `expansions`, terminology lookup, + display lookup, and dynamic `get_profile_class` behavior. + +### Generation And Repository Policy + +- Official HL7 `definitions.json.zip` and `expansions.json` files are generation + inputs. Pin their URLs and SHA-256 checksums, but do not check the downloaded + source artifacts into the repository. +- Generate and check in the R4B Ruby models and the derived, preprocessed + runtime definition files. Normal use of the `fhir_models` gem must not require + a network connection or local copies of the HL7 source downloads. +- Generation must be deterministic. Repeated generation from the pinned inputs + must produce byte-identical Ruby models and runtime definition output. +- Keep the generated definition files as text. Git already compresses repository + objects, while text files retain useful diffs and delta compression that a + generated gzip index would prevent. + +### Harness Structure Index + +- `lib/FHIR_structure_r4b.json` is generated from the pinned R4B + `definitions.json.zip` input and checked into `plan-executor`. +- Regenerate it with + `bundle exec rake "crucible:generate_r4b_structure[path/to/r4b-definitions.json.zip]"`. + The task verifies the pinned source checksum before reading + `profiles-resources.json` from the archive. +- Resource names and categories come from concrete specialization + StructureDefinitions. The existing R4 structure index supplies only the + non-resource hierarchy and category template; it is not the source of the + R4B resource list. +- The official R4B StructureDefinitions omit category extensions for + `ResearchDefinition` and `ResearchElementDefinition`. The generator assigns + both explicitly to `Specialized.Evidence-Based Medicine`. +- The downloaded definitions archive remains an untracked generation input. + Repeated generation from the pinned input must produce byte-identical output. + +### XML Schema Status + +- The generated R4B JSON definition bundles do not contain the R4B XML XSD + schema set; the schemas come from the separately pinned official archive. +- `FHIR::R4B::Xml.validate` uses the R4B `4.3.0` schema set and does not reuse + the R4 `4.0.1` schema directory. +- Generated, preprocessed R4B schemas are checked in under + `lib/fhir_models/definitions/r4b/schema/`, parallel to R4. Use the existing + `FHIR::Boot::Preprocess.pre_process_schema` implementation rather than adding + a separate schema generator or runtime schema abstraction. +- The original downloaded HL7 schema archive remains a checksum-pinned + generation input and is not checked into the repository. + +## Compatibility Annotation Actions + +- Introduce one authoritative registry of FHIR versions understood by the harness. This registry may include `:r4b`, but it must not imply that every suite supports R4B. +- Keep `supported_versions` as the explicit suite compatibility annotation. +- `BaseTest#supported_versions` defaults to an empty list. Do not add `:r4b` or + any other implicit compatibility to that default. +- Audit every suite and add `:r4b` only after its behavior, resources, fixtures, and assertions have been checked against R4B. +- All executable suites now have explicit annotations. Seven suites that + previously relied on the base default explicitly preserve their existing + `[:dstu2, :stu3, :r4]` compatibility; none implicitly gained R4B support. +- Update resource-based suite enumeration so it intersects known versions, the suite's declared `supported_versions`, and resources available in that version. It must not overwrite a suite's declared compatibility. +- Ensure suite listing and suite execution use the same compatibility decision. +- Keep TestScripts STU3-only unless separate R4B TestScripts and an R4B TestScript parser are deliberately added. + +## Implementation Actions + +### 1. R4B Models In `fhir_models` + +- Refactor model generation so the output directory and Ruby namespace are version-aware instead of being hard-coded to the top-level `FHIR` namespace. +- Refactor JSON and XML deserialization, resource detection, validation, metadata, definitions, and schema lookup so they resolve classes and resource lists through the selected model namespace. +- Preserve the current top-level R4 public API while adding generated R4B classes, metadata, parsers, validation, definitions, schemas, and resource lists under `FHIR::R4B`. +- Ensure embedded resources, contained resources, Bundle entries, complex data types, and generated references remain in the R4B namespace. +- Add model-level tests for generation, parsing, serialization, validation, and namespace purity. + +### 2. R4B Routing In `fhir_client` + +- Add `use_r4b` and route `:r4b` resource lookup, parsing, request replay, response validation, transactions, operations, and capability statements through `FHIR::R4B`. +- Make JSON and XML reply parsing select R4B explicitly instead of allowing the existing non-DSTU2/STU3 fallback to use R4. +- Map CapabilityStatement `fhirVersion` values explicitly: `4.0.x` to `:r4` and `4.3.x` to `:r4b`. +- Do not classify every version beginning with `4` as R4. Unknown FHIR 4.x releases must be reported as unsupported rather than silently parsed as R4. +- Test both explicit `use_r4b` selection and automatic version detection. + +### 3. R4B Routing In `plan-executor` + +- Add `r4b` parsing to the rake version resolver and test that omitted versions default to R4 while unknown supplied values fail fast. +- Replace scattered version conditionals with a central namespace resolver where practical. +- Add explicit R4B namespace and resource resolution in `BaseTest`, `BaseSuite`, OperationOutcome parsing, capability statement handling, fixture validation, resource category lookup, and resource generation helpers. +- Initialize R4B base resources with the active client without affecting R4, STU3, or DSTU2 resources. +- Add `lib/FHIR_structure_r4b.json`, generated from the same pinned R4B definitions used by the models. +- Extend structure tests to compare R4B structure metadata against `FHIR::R4B::RESOURCES`, and fix existing structure-root tests so they make assertions. +- Fix version-specific fixture override lookup before adding `*.r4b.xml` or `*.r4b.json` fixtures. +- Version-specific fixture overrides are stored beside the base fixture as + `.r4b.xml` or `.r4b.json`. Lookup falls back to the base fixture + only when no version-specific file exists. Reuse of a base fixture still + requires validation before a suite can declare R4B compatibility. +- Update README and shell usage documentation to list `r4b`. + +## Pitfalls To Avoid + +- Do not let `r4b` fall into top-level `FHIR::Patient` or `FHIR::Bundle` R4 classes by default. +- Do not treat passing R4 smoke tests as proof of R4B support. +- Do not reuse R4 fixtures for R4B unless validation proves they are compatible. +- Do not enable STU3 TestScripts for R4B unless separate R4B TestScripts are added. +- Watch for model/client dependency gaps where R4B model classes exist but parsing, capability statements, or client version activation do not. +- Do not let metadata listing advertise R4B support that execution would reject, or vice versa. +- Do not treat a normative resource as automatically compatible at the suite level; test semantics, search parameters, fixtures, and assertions still require an explicit audit. +- Do not allow generated R4B resources to contain top-level R4 complex types or contained resources. +- Do not publish the harness against a client or model dependency that is only available through an unrecorded local checkout. + +## Validation Milestones + +### Model Validation + +- R4B JSON and XML examples parse into `FHIR::R4B::*` and round-trip successfully. +- Bundle entries and contained resources remain entirely within `FHIR::R4B`. +- R4B resource generation produces valid resources without top-level R4 model instances. +- At least one R4B-only field or changed structure is accepted by R4B validation and rejected by R4 validation, proving that R4B is not an alias. +- R4 behavior and its existing top-level namespace remain unchanged. + +### Client Validation + +- Explicit `use_r4`, `use_r4b`, `use_stu3`, and `use_dstu2` select the expected model namespace. +- CapabilityStatement detection distinguishes FHIR `4.0.x` from `4.3.x` and rejects unsupported versions. +- Read, search, create/update, Bundle, OperationOutcome, transaction, and format handling parse responses through the selected namespace. + +### Harness Validation + +- `bundle exec rake crucible:list_all[r4b]` lists only suites explicitly annotated for R4B. +- Resource-based suites instantiate R4B classes, not R4 classes, and only enumerate resources present in `FHIR::R4B::RESOURCES`. +- Listing and executing suites apply identical version eligibility rules. +- An omitted CLI version still selects R4; an unknown supplied version exits with a clear error. +- `FHIR_structure_r4b.json` passes resource-list consistency and duplicate-name checks. +- Version-specific fixture override selection has focused unit coverage. +- At least one read, search, JSON, and XML smoke path runs against an R4B endpoint. +- Existing R4, STU3, and DSTU2 tests remain unchanged in behavior and pass their regression matrix. + +## Recommended Implementation Order + +1. Complete: reconcile the `fhir_models` baseline and dependency strategy. +2. Complete: refactor `fhir_models` generation and runtime for explicit `FHIR::R4B` support. +3. Complete: generate and validate the R4B model set. +4. Complete: add R4B routing, parsing, capability handling, and detection to `fhir_client`. +5. Complete: add the central version registry and fail-fast version resolution to `plan-executor`. +6. Pending: audit suites and add explicit R4B compatibility annotations only where verified. +7. In progress: R4B structures and documentation are complete; R4B fixtures, + endpoint smoke tests, dependency updates, and the full regression matrix + remain. diff --git a/README.md b/README.md index 803e4105..0d5f7723 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ # Plan Executor [![Build Status](https://travis-ci.org/fhir-crucible/plan_executor.svg?branch=master)](https://travis-ci.org/fhir-crucible/plan_executor) -Plan Executor runs test suites against a FHIR server. It supports `DSTU2`, `STU3` and `R4` versions of FHIR. +Plan Executor runs test suites against a FHIR server. The harness recognizes +`DSTU2`, `STU3`, `R4`, and `R4B` versions of FHIR. Each suite declares its +supported versions explicitly; recognizing a version does not make every suite +compatible with it. Tests can either be written in [Ruby](https://github.com/fhir-crucible/plan_executor#adding-a-new-test-suite), or using the [TestScript Resource](https://github.com/fhir-crucible/plan_executor/wiki/Using-Plan-Executor-with-TestScripts#testscript). +TestScript execution remains STU3-only. ## Getting Started @@ -13,10 +17,13 @@ $ bundle exec rake -T ## Listing Test Suites -List all the available Test Suites, excluding supported `TestScripts`. Pass the version, which can currently be `dstu2`, `stu3` or `r4`. +List all available Test Suites, excluding supported `TestScripts`. Pass the +version, which can be `dstu2`, `stu3`, `r4`, or `r4b`. Only suites explicitly +annotated for the selected version are listed. ``` $ bundle exec rake crucible:list_suites[dstu2] +$ bundle exec rake crucible:list_suites[r4b] ``` ## Executing a Test Suite @@ -24,7 +31,7 @@ $ bundle exec rake crucible:list_suites[dstu2] Crucible tests can be executed by suite from the command-line by calling the `crucible-execute` rake task with the following parameters: * `url` the FHIR endpoint -* `version` the FHIR version (sequence). Currently `dstu2`, `stu3` and `r4` are supported. +* `version` the FHIR version (sequence): `dstu2`, `stu3`, `r4`, or `r4b`. * `test` the name of the test suite (see `crucible:list_suites`) * `resource` (optional) limit the `test` (applicable to "ResourceTest" or "SearchTest" suites) to a given resource (e.g. "Patient") @@ -66,12 +73,13 @@ module Crucible def initialize(client1, client2=nil) super(client1, client2) + @supported_versions = [:r4] @category = {id: 'connectathon', title: 'Connectathon'} end def setup # create any fixtures you need here - @patient = ResourceGenerator.generate(FHIR::Patient,3) + @patient = ResourceGenerator.generate(get_resource(:Patient),3) reply = @client.create(@patient) @id = reply.id @body = reply.body @@ -79,7 +87,7 @@ module Crucible def teardown # perform any clean up here - @client.destroy(FHIR::Patient, @id) + @client.destroy(get_resource(:Patient), @id) end # test 'KEY', 'DESCRIPTION' @@ -91,7 +99,7 @@ module Crucible } assert(@id, 'Setup was unable to create a patient.',@body) - reply = @client.read(FHIR::Patient, @id) + reply = @client.read(get_resource(:Patient), @id) assert_response_ok(reply) assert_equal @id, reply.id, 'Server returned wrong patient.' warning { assert_valid_resource_content_type_present(reply) } @@ -108,6 +116,8 @@ Every Test Suite needs to override the following methods: * `description` The description that is displayed within the Crucible web app * `initialize` Use the example above. Change the `@category` -- the `id` and `title` determine where the test suite is categorized within the Crucible web app +and set `@supported_versions` explicitly. A suite is not eligible for any FHIR +version until that annotation is present. * `setup` (optional) Use this method to create fixtures and perform any required assertions prior to execution of individual `test` blocks. * `test` These blocks are the individual tests within the suites. Each block should start with a `metadata` section so Crucible knows how to tie the success or failures to portions of the FHIR specification (displayed in the web app with a starburst). See `lib/FHIR_structure.json` for the values associated with the `name` keys that you can link to. From af0d3a288e0c2810ec7fc3ce30f0a0153a664df9 Mon Sep 17 00:00:00 2001 From: Kenneth Myhra Date: Thu, 23 Jul 2026 19:22:58 +0200 Subject: [PATCH 07/16] Enable audited FormatTest support for R4B --- R4B.md | 25 ++++-- lib/tests/suites/format_test.rb | 2 +- test/unit/format_suite_test.rb | 129 +++++++++++++++++++++++++++ test/unit/supported_versions_test.rb | 5 +- 4 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 test/unit/format_suite_test.rb diff --git a/R4B.md b/R4B.md index 1a2db746..de2e2f57 100644 --- a/R4B.md +++ b/R4B.md @@ -32,8 +32,9 @@ R4B feature work is expected in `fhir_models`, `fhir_client`, and this repositor routing, a generated R4B structure index, corrected version-specific fixture lookup, and explicit suite compatibility annotations. These changes are split across commits `17537c9a`, `2e7a759`, `d70c01c`, `12e10e8`, and `a2b1482`. -- No executable suite currently declares R4B compatibility. Suites must be - audited individually before `:r4b` is added to their annotations. +- `FormatTest` is the first executable suite audited for R4B compatibility. + All remaining suites must still be audited individually before `:r4b` is + added to their annotations. - Remaining integration work consists of publishing or pinning compatible `fhir_models` and `fhir_client` revisions, updating dependency resolution, auditing candidate suites, and running endpoint smoke tests and the final @@ -157,7 +158,8 @@ R4B feature work is expected in `fhir_models`, `fhir_client`, and this repositor - Audit every suite and add `:r4b` only after its behavior, resources, fixtures, and assertions have been checked against R4B. - All executable suites now have explicit annotations. Seven suites that previously relied on the base default explicitly preserve their existing - `[:dstu2, :stu3, :r4]` compatibility; none implicitly gained R4B support. + compatibility; `FormatTest` gained R4B only after its focused and endpoint + audits. No suite gains R4B support implicitly. - Update resource-based suite enumeration so it intersects known versions, the suite's declared `supported_versions`, and resources available in that version. It must not overwrite a suite's declared compatibility. - Ensure suite listing and suite execution use the same compatibility decision. - Keep TestScripts STU3-only unless separate R4B TestScripts and an R4B TestScript parser are deliberately added. @@ -231,7 +233,13 @@ R4B feature work is expected in `fhir_models`, `fhir_client`, and this repositor - An omitted CLI version still selects R4; an unknown supplied version exits with a clear error. - `FHIR_structure_r4b.json` passes resource-list consistency and duplicate-name checks. - Version-specific fixture override selection has focused unit coverage. -- At least one read, search, JSON, and XML smoke path runs against an R4B endpoint. +- `FormatTest` passes all 22 cases against a Spark endpoint whose + CapabilityStatement reports FHIR `4.3.0` and both `xml` and `json`. The audit + used `sparkfhir/spark:r4b-latest` image + `sha256:720309c969f8562f418948197b794cc01cc07e5be2d7a82873562a8714719e82` + and `sparkfhir/mongo:r4b-latest` image + `sha256:10a44ee9fa2c6a42325656b1758b3fb14be00dfe84a6d999e1b9b3d695fc29d5`. + This covers Patient read, search Bundle, JSON, XML, and format negotiation. - Existing R4, STU3, and DSTU2 tests remain unchanged in behavior and pass their regression matrix. ## Recommended Implementation Order @@ -241,7 +249,8 @@ R4B feature work is expected in `fhir_models`, `fhir_client`, and this repositor 3. Complete: generate and validate the R4B model set. 4. Complete: add R4B routing, parsing, capability handling, and detection to `fhir_client`. 5. Complete: add the central version registry and fail-fast version resolution to `plan-executor`. -6. Pending: audit suites and add explicit R4B compatibility annotations only where verified. -7. In progress: R4B structures and documentation are complete; R4B fixtures, - endpoint smoke tests, dependency updates, and the full regression matrix - remain. +6. In progress: `FormatTest` is audited and enabled; audit additional suites + and add explicit R4B compatibility annotations only where verified. +7. In progress: R4B structures, documentation, and the first endpoint smoke + run are complete; additional R4B fixtures, dependency updates, and the full + cross-version endpoint regression matrix remain. diff --git a/lib/tests/suites/format_test.rb b/lib/tests/suites/format_test.rb index 4e2bec44..ffc05b84 100644 --- a/lib/tests/suites/format_test.rb +++ b/lib/tests/suites/format_test.rb @@ -16,7 +16,7 @@ def description def initialize(client1, client2=nil) super(client1, client2) - @supported_versions = [:dstu2, :stu3, :r4] + @supported_versions = [:dstu2, :stu3, :r4, :r4b] if fhir_version == :dstu2 @xml_format = FHIR::Formats::ResourceFormat::RESOURCE_XML_DSTU2 @json_format = FHIR::Formats::ResourceFormat::RESOURCE_JSON_DSTU2 diff --git a/test/unit/format_suite_test.rb b/test/unit/format_suite_test.rb new file mode 100644 index 00000000..f2f589c9 --- /dev/null +++ b/test/unit/format_suite_test.rb @@ -0,0 +1,129 @@ +require_relative '../test_helper' +require 'webmock/test_unit' + +class FormatSuiteTest < Test::Unit::TestCase + BASE_URL = 'http://format-suite.test/fhir'.freeze + PATIENT_ID = 'format-suite-patient'.freeze + FHIR_VERSIONS = { + stu3: '3.0.2', + r4: '4.0.1', + r4b: '4.3.0' + }.freeze + + FHIR_VERSIONS.each do |version, specification_version| + define_method("test_format_suite_executes_all_cases_with_#{version}_resources") do + execute_format_suite(version, specification_version) + end + end + + private + + def execute_format_suite(version, specification_version) + @namespace = Crucible::FHIRVersion.namespace(version) + @client = FHIR::Client.new(BASE_URL) + @client.use_fhir_version(version) + @created_patient = nil + stub_capability_statement(specification_version) + stub_create + stub_reads + stub_delete + + suite = Crucible::Tests::FormatTest.new(@client) + tests = suite.execute.fetch('Format001') + + assert_equal 22, tests.length + assert_true tests.all? { |test| test['status'] == 'pass' }, failure_summary(tests) + assert_instance_of @namespace.const_get(:Patient), @created_patient + assert_include suite.supported_versions, version + end + + def stub_capability_statement(specification_version) + capability_statement = @namespace.const_get(:CapabilityStatement).new( + status: 'active', + date: '2026-07-17', + kind: 'instance', + fhirVersion: specification_version, + format: ['xml', 'json'] + ) + + stub_request(:get, "#{BASE_URL}/metadata").to_return( + status: 200, + body: capability_statement.to_json, + headers: { 'Content-Type' => FHIR::Formats::ResourceFormat::RESOURCE_JSON } + ) + end + + def stub_create + stub_request(:post, "#{BASE_URL}/Patient").to_return do |request| + @created_patient = @namespace.from_contents(request.body) + @created_patient.id = PATIENT_ID + @created_patient.meta ||= @namespace.const_get(:Meta).new + @created_patient.meta.versionId = '1' + @created_patient.meta.lastUpdated = '2026-07-17T12:00:00Z' + + { + status: 201, + body: @created_patient.to_json, + headers: { + 'Content-Type' => FHIR::Formats::ResourceFormat::RESOURCE_JSON, + 'Location' => "#{BASE_URL}/Patient/#{PATIENT_ID}/_history/1" + } + } + end + end + + def stub_reads + stub_request(:get, %r{\A#{Regexp.escape(BASE_URL)}/Patient(?:/#{PATIENT_ID})?(?:\?.*)?\z}).to_return do |request| + requested_format = request_format(request) + next { status: 406 } if requested_format.include?('application/foobar') + + resource = if request.uri.path.end_with?("/Patient/#{PATIENT_ID}") + @created_patient + else + search_bundle + end + xml = requested_format.downcase.include?('xml') + + { + status: 200, + body: xml ? resource.to_xml : resource.to_json, + headers: { + 'Content-Type' => xml ? FHIR::Formats::ResourceFormat::RESOURCE_XML : + FHIR::Formats::ResourceFormat::RESOURCE_JSON + } + } + end + end + + def stub_delete + stub_request( + :delete, + %r{\A#{Regexp.escape(BASE_URL)}/Patient/#{PATIENT_ID}(?:\?.*)?\z} + ).to_return(status: 204) + end + + def request_format(request) + query_format = request.uri.query_values&.fetch('_format', nil) + (query_format || request.headers['Accept'] || '').to_s + end + + def search_bundle + bundle_class = @namespace.const_get(:Bundle) + bundle_class.new( + type: 'searchset', + total: 1, + entry: [ + { + 'fullUrl' => "#{BASE_URL}/Patient/#{PATIENT_ID}", + 'resource' => @created_patient.to_hash + } + ] + ) + end + + def failure_summary(tests) + tests.reject { |test| test['status'] == 'pass' }.map do |test| + "#{test[:test_method]}: #{test['status']} #{test['message']}" + end.join("\n") + end +end diff --git a/test/unit/supported_versions_test.rb b/test/unit/supported_versions_test.rb index 281a338a..9580d708 100644 --- a/test/unit/supported_versions_test.rb +++ b/test/unit/supported_versions_test.rb @@ -18,9 +18,10 @@ def test_resource_suites_preserve_their_existing_version_support assert_equal expected, Crucible::Tests::SearchTest.new(nil).supported_versions end - def test_no_suite_implicitly_advertises_r4b + def test_only_audited_suites_advertise_r4b suites = Crucible::Tests::SuiteEngine.new.tests + r4b_suites = suites.select { |suite| suite.supported_versions.include?(:r4b) } - assert_true suites.none? { |suite| suite.supported_versions.include?(:r4b) } + assert_equal [Crucible::Tests::FormatTest], r4b_suites.map(&:class) end end From 13d7c7f864bcb344a1d912d37e859cdb673adf3b Mon Sep 17 00:00:00 2001 From: Kenneth Myhra Date: Thu, 23 Jul 2026 19:23:24 +0200 Subject: [PATCH 08/16] Document the full R4B endpoint audit --- R4B.md | 161 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/R4B.md b/R4B.md index de2e2f57..1f913fde 100644 --- a/R4B.md +++ b/R4B.md @@ -242,6 +242,167 @@ R4B feature work is expected in `fhir_models`, `fhir_client`, and this repositor This covers Patient read, search Bundle, JSON, XML, and format negotiation. - Existing R4, STU3, and DSTU2 tests remain unchanged in behavior and pass their regression matrix. +## R4B Full-Suite Endpoint Audit (2026-07-17) + +### Scope And Execution + +- This was a diagnostic run against the local Spark R4B endpoint at + `http://localhost:18080/fhir`. It used the same Spark and Mongo image digests + recorded for the FormatTest audit above. +- The run used temporary `path:` dependencies for the local `../fhir_models`, + `../fhir_client`, `../fhir_stu3_models`, and `../fhir_dstu2_models` + repositories so the unpublished R4B model and client changes were loaded. +- Only suites whose existing `supported_versions` declaration contained `:r4` + were temporarily given `:r4b`. STU3-only, DSTU2-only, TestScript, and + explicitly unsupported suites were not enabled. These temporary annotations + were made in an isolated copy and are not evidence that every suite should be + permanently annotated for R4B. +- The normal suite registry exposed 12 eligible suites. Eligibility was checked + with: + + ```sh + bundle exec rake "crucible:list_suites[r4b]" + ``` + +- Each suite was then run separately through the documented Rake entry point: + + ```sh + bundle exec rake "crucible:execute[http://localhost:18080/fhir,r4b,SUITE_NAME,,stdout]" + ``` + + The empty resource argument caused `ResourceTest` and `SearchTest` to exercise + every R4B resource rather than a single named resource. Suites were run + sequentially because they create, update, and delete shared endpoint data. +- The test process must be allowed to connect to the local endpoint. A sandboxed + attempt produced `Operation not permitted` TCP errors and invalid all-skip or + all-error results; those results were discarded. The Rake task also expects + the local `logs/` directory to exist. + +### Results + +| Suite | Pass | Fail | Error | TODO skip | Exit | +| --- | ---: | ---: | ---: | ---: | ---: | +| `SprinklerSearchTest` | 36 | 0 | 0 | 2 | 0 | +| `ConsentSearchByPatientReferenceTest` | 1 | 0 | 0 | 0 | 0 | +| `ElementsSearchParameterTest` | 1 | 0 | 0 | 1 | 0 | +| `UnknownSearchParameterTest` | 12 | 0 | 0 | 0 | 0 | +| `ReadTest` | 5 | 0 | 0 | 0 | 0 | +| `ResourceTest` | 2075 | 10 | 0 | 417 | 1 | +| `FhirPathPatchTest` | 4 | 0 | 0 | 2 | 0 | +| `FormatTest` | 22 | 0 | 0 | 0 | 0 | +| `TransactionAndBatchTest` | 2 | 6 | 0 | 5 | 1 | +| `SearchTest` | 973 | 0 | 0 | 0 | 0 | +| `HistoryTest` | 10 | 0 | 0 | 0 | 0 | +| `RobustSearchTest` | 0 | 0 | 0 | 1 | 0 | +| **Total** | **3141** | **16** | **0** | **428** | **2 suites failed** | + +All 428 skips were existing `TODO` skips. They do not cause the Rake task to +exit non-zero. The 16 failures are concentrated in the two suites shown above; +they are not 16 independent compatibility defects. + +### Failure Areas + +#### 1. R4B Condition Status Conversion + +- `TransactionAndBatchTest` fails first in `XFER0`. The generated transaction + serializes `Condition.verificationStatus` as the primitive string + `"confirmed"`, while R4B requires a `CodeableConcept`. +- Spark returns HTTP 400 with an OperationOutcome reporting that it encountered + a JSON primitive where a non-primitive `verificationStatus` object was + required. Five later transaction assertions then fail because they depend on + `XFER0` having created the patient record. +- `ResourceGenerator.fix_condition` currently converts R4 status strings only + when `resource.is_a?(FHIR::Condition)`. A `FHIR::R4B::Condition` does not + satisfy that check, so the existing R4 compatibility correction is skipped. +- The correction must become namespace-aware and cover both + `clinicalStatus` and `verificationStatus` without making R4B inherit from or + fall back to the R4 model class. Add focused serialization coverage before + rerunning `TransactionAndBatchTest`. + +#### 2. Recursive PackagedProductDefinition Generation + +- `ResourceTest` deterministically generates invalid deeply nested + `PackagedProductDefinition` resources. At the generator recursion boundary, + `package.package[].package[].containedItem[].item` is omitted even though its + minimum cardinality is one. +- Spark rejects these resources with HTTP 400. The initial create failures then + cause conditional create, conditional update, and history assertions to fail + or operate on incomplete setup state. +- A targeted documented run reproduces the problem: + + ```sh + bundle exec rake "crucible:execute[http://localhost:18080/fhir,r4b,ResourceTest,PackagedProductDefinition,stdout]" + ``` + +- Fixing this requires a finite minimal representation for the recursive + package structure. The recursion guard must still prevent infinite trees, but + it cannot terminate by omitting a required child. Add a generator test that + validates the generated JSON or XML against the R4B model/schema. + +#### 3. Abstract Questionnaire Item Code Generation + +- The generated R4B metadata for `Questionnaire.item.type` includes the + abstract code `question` in `valid_codes`. `ResourceGenerator` samples from + that list and may emit `type: "question"` in one or more nested items. +- Spark rejects that value because it is not a selectable + `QuestionnaireItemType`. The exact number of failed ResourceTest assertions + varies with random generation; a targeted rerun reproduced failures in + create and update operations. +- Reproduce with: + + ```sh + bundle exec rake "crucible:execute[http://localhost:18080/fhir,r4b,ResourceTest,Questionnaire,stdout]" + ``` + +- Preserve the complete terminology definitions needed at runtime, but prevent + abstract or non-selectable codes from being chosen for generated resource + instances. Add deterministic coverage proving that generated Questionnaire + items use only concrete item types. + +### Follow-Up Sequence + +1. Add focused failing tests for R4B Condition status conversion, + PackagedProductDefinition recursion, and Questionnaire item-type selection. +2. Make the resource generator namespace-aware where it currently dispatches + only on top-level R4 classes. Review the rest of `apply_invariants!` for the + same pattern before adding broad R4B suite annotations. +3. Fix and rerun the three targeted commands above, including + `TransactionAndBatchTest` through `crucible:execute`. +4. Repeat the complete 12-suite R4B endpoint run and retain per-suite output and + shell exit status. +5. Permanently add `:r4b` only to suites that pass their focused audit, then run + the existing R4 unit and endpoint regression suites to detect shared + generator regressions. + +### Existing Endpoint Regression Baselines + +The following existing endpoint results were provided on 2026-07-17. They were +not rerun as part of the R4B audit, but should be retained as the comparison +baseline for later cross-version regression runs. + +| Version | Pass | Fail | Error | Skip | +| --- | ---: | ---: | ---: | ---: | +| STU3 | 2876 | 0 | 0 | 413 | +| R4 | 3267 | 0 | 0 | 443 | + +Future runs should compare both the totals and the individual skipped tests. +The totals alone do not establish whether a changed skip is expected. + +### Deferred STU3 TestScript Regression + +- Run the 71 FHIR TestScript artifacts against a STU3 endpoint as a separate + regression exercise. They remain explicitly STU3-only and are not part of + either the R4 or R4B suite runs. +- Before relying on that run, correct or work around the dedicated + `crucible:execute_all_testscripts` and `crucible:testreport` tasks. They do not + currently accept a FHIR version, do not call `use_fhir_version`, and bypass + the normal `supported_versions` filter. Because a new `FHIR::Client` defaults + to R4, invoking those tasks as written does not guarantee a STU3 client even + though the TestScript artifacts are parsed and annotated as STU3. +- The eventual regression command must explicitly select `:stu3`, retain + per-TestScript output and shell exit status, and use a CapabilityStatement to + confirm that the target endpoint reports STU3 before execution. + ## Recommended Implementation Order 1. Complete: reconcile the `fhir_models` baseline and dependency strategy. From 16347df69eaacb16d8c78168bd5b7f7e3646f293 Mon Sep 17 00:00:00 2001 From: Kenneth Myhra Date: Thu, 23 Jul 2026 22:12:31 +0200 Subject: [PATCH 09/16] Require explicit FHIR versions in the harness --- README.md | 2 + lib/data/fhir_structure.rb | 2 +- lib/data/resources.rb | 2 +- lib/fhir_version.rb | 12 +- lib/resource_generator.rb | 267 +++++++++--------- lib/tasks/tasks.rake | 33 ++- lib/tests/base_test.rb | 4 +- lib/tests/suites/base_suite.rb | 10 +- .../suites/connectathon_terminology_track.rb | 4 +- lib/tests/suites/format_test.rb | 2 +- ...consent_search_by_patient_reference_329.rb | 2 +- .../incendi_elements_search_parameter.rb | 2 +- lib/tests/suites/search_test_robust.rb | 2 +- lib/tests/suites/suite_engine.rb | 7 +- lib/tests/suites/transaction_test.rb | 38 +-- lib/tests/testscripts/base_testscript.rb | 8 + lib/tests/testscripts/testscript_engine.rb | 2 +- lib/uscore_resource_generator.rb | 17 +- test/unit/fhir_structure_test.rb | 2 +- test/unit/fhir_version_test.rb | 17 +- test/unit/format_suite_test.rb | 3 +- test/unit/metadata_test.rb | 6 + test/unit/r4b_routing_test.rb | 51 +++- 23 files changed, 293 insertions(+), 202 deletions(-) diff --git a/README.md b/README.md index 0d5f7723..6bb55fd2 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Plan Executor runs test suites against a FHIR server. The harness recognizes `DSTU2`, `STU3`, `R4`, and `R4B` versions of FHIR. Each suite declares its supported versions explicitly; recognizing a version does not make every suite compatible with it. +Commands that execute suites require this version argument; omission is an +error and does not select R4 implicitly. Tests can either be written in [Ruby](https://github.com/fhir-crucible/plan_executor#adding-a-new-test-suite), or using the [TestScript Resource](https://github.com/fhir-crucible/plan_executor/wiki/Using-Plan-Executor-with-TestScripts#testscript). TestScript execution remains STU3-only. diff --git a/lib/data/fhir_structure.rb b/lib/data/fhir_structure.rb index 8e2f43bc..2bc1b984 100644 --- a/lib/data/fhir_structure.rb +++ b/lib/data/fhir_structure.rb @@ -1,6 +1,6 @@ module Crucible class FHIRStructure - def self.get(fhir_version = :r4) + def self.get(fhir_version) root = File.expand_path File.join('..','..'), File.dirname(File.absolute_path(__FILE__)) JSON.parse(File.read(File.join(root, 'lib', "FHIR_structure_#{fhir_version.to_s}.json"))) end diff --git a/lib/data/resources.rb b/lib/data/resources.rb index 2722c104..7db34a87 100644 --- a/lib/data/resources.rb +++ b/lib/data/resources.rb @@ -4,7 +4,7 @@ class Resources FIXTURE_DIR = File.join(File.expand_path(File.join('..','..','..'),File.absolute_path(__FILE__)), 'fixtures') - def initialize(fhir_version = nil) + def initialize(fhir_version) @fhir_version = fhir_version @namespace = Crucible::FHIRVersion.namespace(@fhir_version) end diff --git a/lib/fhir_version.rb b/lib/fhir_version.rb index 57631d48..b9587659 100644 --- a/lib/fhir_version.rb +++ b/lib/fhir_version.rb @@ -1,6 +1,5 @@ module Crucible module FHIRVersion - DEFAULT = :r4 NAMESPACES = { dstu2: 'FHIR::DSTU2', stu3: 'FHIR::STU3', @@ -11,9 +10,12 @@ module FHIRVersion class UnsupportedVersionError < ArgumentError; end - def self.resolve(value = nil) + def self.resolve(value) normalized = value.to_s.strip.downcase - return DEFAULT if normalized.empty? + if normalized.empty? + raise UnsupportedVersionError, + "FHIR version is required. Supported versions: #{KNOWN.join(', ')}" + end version = normalized.to_sym return version if KNOWN.include?(version) @@ -22,13 +24,13 @@ def self.resolve(value = nil) "Unsupported FHIR version '#{value}'. Supported versions: #{KNOWN.join(', ')}" end - def self.namespace(value = nil) + def self.namespace(value) namespace_name(value).split('::').inject(Object) do |parent, name| parent.const_get(name) end end - def self.namespace_name(value = nil) + def self.namespace_name(value) NAMESPACES.fetch(resolve(value)) end diff --git a/lib/resource_generator.rb b/lib/resource_generator.rb index 60557817..f9d89321 100644 --- a/lib/resource_generator.rb +++ b/lib/resource_generator.rb @@ -180,11 +180,11 @@ def self.random_oid oid end - def self.minimal_patient(identifier='0',name='Name', namespace = FHIR) + def self.minimal_patient(identifier='0', name='Name', namespace:) resource = namespace.const_get(:Patient).new - resource.identifier = [ minimal_identifier(identifier) ] - resource.name = [ minimal_humanname(name) ] - tag_metadata(resource) + resource.identifier = [minimal_identifier(identifier, namespace: namespace)] + resource.name = [minimal_humanname(name, namespace: namespace)] + tag_metadata(resource, namespace: namespace) end # Common systems: @@ -192,21 +192,21 @@ def self.minimal_patient(identifier='0',name='Name', namespace = FHIR) # LOINC http://loinc.org # ICD-10 http://hl7.org/fhir/sid/icd-10 # units: must be UCOM - def self.minimal_observation(system='http://loinc.org',code='8302-2',value=170,units='cm',patientId=nil, namespace = FHIR) + def self.minimal_observation(system='http://loinc.org', code='8302-2', value=170, units='cm', patientId=nil, namespace:) resource = namespace.const_get(:Observation).new resource.status = 'final' - resource.code = minimal_codeableconcept(system,code, namespace) + resource.code = minimal_codeableconcept(system, code, namespace: namespace) if patientId ref = namespace.const_get(:Reference).new ref.reference = "Patient/#{patientId}" resource.subject = ref end - resource.valueQuantity = minimal_quantity(value,units, namespace) - tag_metadata(resource) + resource.valueQuantity = minimal_quantity(value, units, namespace: namespace) + tag_metadata(resource, namespace: namespace) end # Default system/code are for SNOMED "Obese (finding)" - def self.minimal_condition(system='http://snomed.info/sct',code='414915002',patientId=nil, namespace = FHIR, patientRef=nil) + def self.minimal_condition(system='http://snomed.info/sct', code='414915002', patientId=nil, namespace:, patient_ref: nil) resource = namespace.const_get(:Condition).new if resource.is_a?(FHIR::DSTU2::Condition) resource.patient = namespace.const_get(:Reference).new @@ -215,8 +215,8 @@ def self.minimal_condition(system='http://snomed.info/sct',code='414915002',pati else resource.patient.display = 'Patient' end - if (patientRef) - resource.patient.reference = patientRef + if patient_ref + resource.patient.reference = patient_ref end else resource.subject = namespace.const_get(:Reference).new @@ -225,17 +225,17 @@ def self.minimal_condition(system='http://snomed.info/sct',code='414915002',pati else resource.subject.display = 'Patient' end - if (patientRef) - resource.subject.reference = patientRef + if patient_ref + resource.subject.reference = patient_ref end end - resource.code = minimal_codeableconcept(system,code, namespace) + resource.code = minimal_codeableconcept(system, code, namespace: namespace) resource.verificationStatus = 'confirmed' fix_condition(resource) - tag_metadata(resource) + tag_metadata(resource, namespace: namespace) end - def self.minimal_identifier(identifier='0', namespace = FHIR) + def self.minimal_identifier(identifier='0', namespace:) mid = namespace.const_get(:Identifier).new mid.use = 'official' mid.system = 'http://projectcrucible.org' @@ -243,7 +243,7 @@ def self.minimal_identifier(identifier='0', namespace = FHIR) mid end - def self.minimal_humanname(name='Name', namespace = FHIR) + def self.minimal_humanname(name='Name', namespace:) hn = namespace.const_get(:HumanName).new hn.use = 'official' hn.family = 'Crucible' @@ -252,13 +252,13 @@ def self.minimal_humanname(name='Name', namespace = FHIR) hn end - def self.textonly_codeableconcept(text='text', namespace = FHIR) + def self.textonly_codeableconcept(text='text', namespace:) concept = namespace.const_get(:CodeableConcept).new concept.text = text concept end - def self.textonly_reference(text='Reference', namespace = FHIR) + def self.textonly_reference(text='Reference', namespace:) ref = namespace.const_get(:Reference).new ref.display = "#{text} #{SecureRandom.base64}" ref @@ -268,9 +268,9 @@ def self.textonly_reference(text='Reference', namespace = FHIR) # SNOMED http://snomed.info/sct # LOINC http://loinc.org # ICD-10 http://hl7.org/fhir/sid/icd-10 - def self.minimal_codeableconcept(system='http://loinc.org',code='8302-2', namespace = FHIR) + def self.minimal_codeableconcept(system='http://loinc.org', code='8302-2', namespace:) concept = namespace.const_get(:CodeableConcept).new - concept.coding = [ minimal_coding(system,code,namespace) ] + concept.coding = [minimal_coding(system, code, namespace: namespace)] concept end @@ -278,14 +278,14 @@ def self.minimal_codeableconcept(system='http://loinc.org',code='8302-2', namesp # SNOMED http://snomed.info/sct # LOINC http://loinc.org # ICD-10 http://hl7.org/fhir/sid/icd-10 - def self.minimal_coding(system='http://loinc.org',code='8302-2',namespace = FHIR) + def self.minimal_coding(system='http://loinc.org', code='8302-2', namespace:) coding = namespace.const_get(:Coding).new coding.system = system coding.code = code coding end - def self.minimal_quantity(value=170,units='cm', namespace = FHIR) + def self.minimal_quantity(value=170, units='cm', namespace:) quantity = namespace.const_get(:Quantity).new quantity.value = value quantity.unit = units @@ -293,38 +293,41 @@ def self.minimal_quantity(value=170,units='cm', namespace = FHIR) quantity end - def self.minimal_animal(namespace = FHIR) + def self.minimal_animal(namespace:) animal = namespace.const_get(:Patient).const_get(:Animal).new - animal.species = minimal_codeableconcept('http://hl7.org/fhir/animal-species','canislf', namespaec) # dog - animal.breed = minimal_codeableconcept('http://hl7.org/fhir/animal-breed','gret', namespaec) # golden retriever - animal.genderStatus = minimal_codeableconcept('http://hl7.org/fhir/animal-genderstatus','intact', namespaec) # intact + animal.species = minimal_codeableconcept('http://hl7.org/fhir/animal-species', 'canislf', namespace: namespace) # dog + animal.breed = minimal_codeableconcept('http://hl7.org/fhir/animal-breed', 'gret', namespace: namespace) # golden retriever + animal.genderStatus = minimal_codeableconcept('http://hl7.org/fhir/animal-genderstatus', 'intact', namespace: namespace) # intact animal end - def self.tag_metadata(resource, namespace = FHIR) + def self.tag_metadata(resource, namespace:) return nil unless resource if resource.meta.nil? resource.meta = namespace.const_get(:Meta).new({ 'tag' => [{'system'=>'http://projectcrucible.org', 'code'=>'testdata'}]}) else - resource.meta.tag << @namespace.const_get(:Coding).new({'system'=>'http://projectcrucible.org', 'code'=>'testdata'}) + resource.meta.tag << namespace.const_get(:Coding).new({'system'=>'http://projectcrucible.org', 'code'=>'testdata'}) end resource end def self.fix_condition(resource) - if resource.is_a?(FHIR::Condition) - # They changed it from `code` data type to `CodeableConcept` in `R4` - if resource.clinicalStatus.kind_of? String - resource.clinicalStatus = minimal_codeableconcept( - 'http://terminology.hl7.org/CodeSystem/condition-clinical', - resource.clinicalStatus) - end - if resource.verificationStatus.kind_of? String - resource.verificationStatus = minimal_codeableconcept( - 'http://terminology.hl7.org/CodeSystem/condition-ver-status', - resource.verificationStatus) - end + version = Crucible::FHIRVersion.for_class(resource) + return resource unless [:r4, :r4b].include?(version) + + namespace = Crucible::FHIRVersion.namespace(version) + if resource.clinicalStatus.kind_of? String + resource.clinicalStatus = minimal_codeableconcept( + 'http://terminology.hl7.org/CodeSystem/condition-clinical', + resource.clinicalStatus, + namespace: namespace) + end + if resource.verificationStatus.kind_of? String + resource.verificationStatus = minimal_codeableconcept( + 'http://terminology.hl7.org/CodeSystem/condition-ver-status', + resource.verificationStatus, + namespace: namespace) end resource end @@ -349,10 +352,10 @@ def self.apply_invariants!(resource) resource.unit = nil resource.comparator = nil when FHIR::Appointment - resource.reasonCode = [ minimal_codeableconcept('http://snomed.info/sct','219006') ] # drinker of alcohol - resource.participant.each{|p| p.type=[ minimal_codeableconcept('http://hl7.org/fhir/participant-type','emergency') ] } + resource.reasonCode = [ minimal_codeableconcept('http://snomed.info/sct','219006', namespace: FHIR) ] # drinker of alcohol + resource.participant.each{|p| p.type=[ minimal_codeableconcept('http://hl7.org/fhir/participant-type','emergency', namespace: FHIR) ] } when FHIR::AppointmentResponse - resource.participantType = [ minimal_codeableconcept('http://hl7.org/fhir/participant-type','emergency') ] + resource.participantType = [ minimal_codeableconcept('http://hl7.org/fhir/participant-type','emergency', namespace: FHIR) ] when FHIR::AuditEvent resource.entity.each do |o| o.query=nil @@ -419,18 +422,18 @@ def self.apply_invariants!(resource) end when FHIR::ClaimResponse resource.item.each do |item| - item.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit')} + item.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit', namespace: FHIR)} item.detail.each do |detail| - detail.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit')} + detail.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit', namespace: FHIR)} detail.subDetail.each do |sub| - sub.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit')} + sub.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit', namespace: FHIR)} end end end resource.addItem.each do |addItem| - addItem.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit')} + addItem.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit', namespace: FHIR)} addItem.detail.each do |detail| - detail.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit')} + detail.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit', namespace: FHIR)} end end when FHIR::Communication @@ -588,7 +591,7 @@ def self.apply_invariants!(resource) code = nil end resource.outcomeReference.each do |reference| - reference = textonly_reference('Observation') + reference = textonly_reference('Observation', namespace: FHIR) end resource.target.each do |target| unless target.dueDuration.nil? @@ -605,7 +608,7 @@ def self.apply_invariants!(resource) availability = ['ONLINE', 'OFFLINE', 'NEARLINE', 'UNAVAILABLE'] resource.series.each do |series| series.instance.each do |instance| - instance.sopClass = minimal_coding('urn:ietf:rfc:3986', random_oid) + instance.sopClass = minimal_coding('urn:ietf:rfc:3986', random_oid, namespace: FHIR) end end when FHIR::Immunization @@ -643,25 +646,25 @@ def self.apply_invariants!(resource) date = DateTime.now resource.effectiveDateTime = date.strftime("%Y-%m-%dT%T.%LZ") resource.effectivePeriod = nil - resource.medicationReference = textonly_reference('Medication') + resource.medicationReference = textonly_reference('Medication', namespace: FHIR) resource.medicationCodeableConcept = nil unless resource.dosage.nil? resource.dosage.dose.comparator = nil unless resource.dosage.dose.nil? resource.dosage.rateQuantity = nil end when FHIR::MedicationDispense - resource.medicationReference = textonly_reference('Medication') + resource.medicationReference = textonly_reference('Medication', namespace: FHIR) resource.medicationCodeableConcept = nil resource.dosageInstruction.each {|d|d.timing = nil } resource.quantity.comparator = nil unless resource.quantity.nil? resource.daysSupply.comparator = nil unless resource.daysSupply.nil? when FHIR::MedicationRequest - resource.medicationReference = textonly_reference('Medication') + resource.medicationReference = textonly_reference('Medication', namespace: FHIR) resource.medicationCodeableConcept = nil resource.dosageInstruction.each {|d|d.timing = nil } resource.dispenseRequest.quantity.comparator = nil if resource&.dispenseRequest&.quantity != nil when FHIR::MedicationStatement - resource.medicationReference = textonly_reference('Medication') + resource.medicationReference = textonly_reference('Medication', namespace: FHIR) resource.medicationCodeableConcept = nil resource.dosage.each{|d|d.timing=nil} when FHIR::MessageDefinition @@ -719,7 +722,7 @@ def self.apply_invariants!(resource) p.searchType = nil unless p.type == 'string' end when FHIR::Patient - resource.maritalStatus = minimal_codeableconcept('http://hl7.org/fhir/v3/MaritalStatus','S') + resource.maritalStatus = minimal_codeableconcept('http://hl7.org/fhir/v3/MaritalStatus','S', namespace: FHIR) when FHIR::PlanDefinition resource.action.each do |a| a.action.each do |b| @@ -729,7 +732,7 @@ def self.apply_invariants!(resource) when FHIR::Procedure resource.focalDevice.each do |fd| code = ['implanted', 'explanted', 'manipulated'].sample - fd.action = minimal_codeableconcept('http://hl7.org/fhir/device-action', code) + fd.action = minimal_codeableconcept('http://hl7.org/fhir/device-action', code, namespace: FHIR) end when FHIR::Provenance resource.entity.each do |e| @@ -743,7 +746,7 @@ def self.apply_invariants!(resource) end end when FHIR::RelatedPerson - resource.relationship = [minimal_codeableconcept('http://hl7.org/fhir/patient-contact-relationship','family')] + resource.relationship = [minimal_codeableconcept('http://hl7.org/fhir/patient-contact-relationship','family', namespace: FHIR)] when FHIR::Questionnaire # resource.item.each do |i| # i.required = true @@ -795,7 +798,7 @@ def self.apply_invariants!(resource) when FHIR::SampledData resource.origin.comparator = nil unless resource.origin.nil? when FHIR::Signature - resource.type = [ minimal_coding('urn:iso-astm:E1762-95:2013','1.2.840.10065.1.12.1.18') ] + resource.type = [ minimal_coding('urn:iso-astm:E1762-95:2013','1.2.840.10065.1.12.1.18', namespace: FHIR) ] resource.targetFormat = nil resource.sigFormat = nil when FHIR::Specimen @@ -815,10 +818,10 @@ def self.apply_invariants!(resource) instance.quantity.comparator = nil unless instance.quantity.nil? end when FHIR::SupplyDelivery - resource.type = minimal_codeableconcept('http://hl7.org/fhir/supply-item-type','medication') + resource.type = minimal_codeableconcept('http://hl7.org/fhir/supply-item-type','medication', namespace: FHIR) resource.suppliedItem.quantity.comparator = nil if !resource.suppliedItem.nil? && !resource.suppliedItem.quantity.nil? when FHIR::SupplyRequest - resource.category = minimal_codeableconcept('http://hl7.org/fhir/supply-kind','central') + resource.category = minimal_codeableconcept('http://hl7.org/fhir/supply-kind','central', namespace: FHIR) when FHIR::StructureDefinition resource.derivation = 'constraint' resource.fhirVersion = '4.0.0' @@ -999,10 +1002,10 @@ def self.apply_invariants!(resource) resource.unit = nil resource.comparator = nil when FHIR::STU3::Appointment - resource.reason = [ minimal_codeableconcept('http://snomed.info/sct','219006', FHIR::STU3) ] # drinker of alcohol - resource.participant.each{|p| p.type=[ minimal_codeableconcept('http://hl7.org/fhir/participant-type','emergency', FHIR::STU3) ] } + resource.reason = [ minimal_codeableconcept('http://snomed.info/sct','219006', namespace: FHIR::STU3) ] # drinker of alcohol + resource.participant.each{|p| p.type=[ minimal_codeableconcept('http://hl7.org/fhir/participant-type','emergency', namespace: FHIR::STU3) ] } when FHIR::STU3::AppointmentResponse - resource.participantType = [ minimal_codeableconcept('http://hl7.org/fhir/participant-type','emergency', FHIR::STU3) ] + resource.participantType = [ minimal_codeableconcept('http://hl7.org/fhir/participant-type','emergency', namespace: FHIR::STU3) ] when FHIR::STU3::AuditEvent resource.entity.each do |o| o.query=nil @@ -1065,7 +1068,7 @@ def self.apply_invariants!(resource) end when FHIR::STU3::Claim resource.item.each do |item| - item.category = minimal_codeableconcept('http://hl7.org/fhir/benefit-subcategory','35', FHIR::STU3) + item.category = minimal_codeableconcept('http://hl7.org/fhir/benefit-subcategory','35', namespace: FHIR::STU3) item.quantity.comparator = nil unless item.quantity.nil? item.detail.each do |detail| detail.category = item.category @@ -1073,24 +1076,24 @@ def self.apply_invariants!(resource) detail.subDetail.each do |sub| sub.category = item.category sub.quantity.comparator = nil unless sub.quantity.nil? - sub.service = minimal_codeableconcept('http://hl7.org/fhir/ex-USCLS','1205', FHIR::STU3) + sub.service = minimal_codeableconcept('http://hl7.org/fhir/ex-USCLS','1205', namespace: FHIR::STU3) end end end when FHIR::STU3::ClaimResponse resource.item.each do |item| - item.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit', FHIR::STU3)} + item.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit', namespace: FHIR::STU3)} item.detail.each do |detail| - detail.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit', FHIR::STU3)} + detail.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit', namespace: FHIR::STU3)} detail.subDetail.each do |sub| - sub.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit', FHIR::STU3)} + sub.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit', namespace: FHIR::STU3)} end end end resource.addItem.each do |addItem| - addItem.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit', FHIR::STU3)} + addItem.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit', namespace: FHIR::STU3)} addItem.detail.each do |detail| - detail.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit', FHIR::STU3)} + detail.adjudication.each{|a|a.category = minimal_codeableconcept('http://hl7.org/fhir/adjudication','benefit', namespace: FHIR::STU3)} end end when FHIR::STU3::Communication @@ -1109,10 +1112,10 @@ def self.apply_invariants!(resource) end when FHIR::STU3::ConceptMap if(resource.sourceUri.nil? && resource.sourceReference.nil?) - resource.sourceReference = textonly_reference('ValueSet', FHIR::STU3) + resource.sourceReference = textonly_reference('ValueSet', namespace: FHIR::STU3) end if(resource.targetUri.nil? && resource.targetReference.nil?) - resource.targetReference = textonly_reference('ValueSet', FHIR::STU3) + resource.targetReference = textonly_reference('ValueSet', namespace: FHIR::STU3) end when FHIR::STU3::Condition if resource.onsetAge @@ -1147,7 +1150,7 @@ def self.apply_invariants!(resource) resource.messaging.each{|m| m.endpoint = nil} if resource.kind != 'instance' when FHIR::STU3::Contract resource.agent.each do |agent| - agent.actor = textonly_reference('Patient', FHIR::STU3) + agent.actor = textonly_reference('Patient', namespace: FHIR::STU3) end resource.valuedItem.each do |item| if item.unitPrice @@ -1166,11 +1169,11 @@ def self.apply_invariants!(resource) end resource.term.each do |term| term.agent.each do |agent| - agent.actor = textonly_reference('Organization', FHIR::STU3) + agent.actor = textonly_reference('Organization', namespace: FHIR::STU3) end term.group.each do |group| group.agent.each do |agent| - agent.actor = textonly_reference('Organization', FHIR::STU3) + agent.actor = textonly_reference('Organization', namespace: FHIR::STU3) end end term.valuedItem.each do |item| @@ -1191,15 +1194,15 @@ def self.apply_invariants!(resource) end resource.friendly.each do |f| f.contentAttachment = nil - f.contentReference = textonly_reference('DocumentReference', FHIR::STU3) + f.contentReference = textonly_reference('DocumentReference', namespace: FHIR::STU3) end resource.legal.each do |f| f.contentAttachment = nil - f.contentReference = textonly_reference('DocumentReference', FHIR::STU3) + f.contentReference = textonly_reference('DocumentReference', namespace: FHIR::STU3) end resource.rule.each do |f| f.contentAttachment = nil - f.contentReference = textonly_reference('DocumentReference', FHIR::STU3) + f.contentReference = textonly_reference('DocumentReference', namespace: FHIR::STU3) end when FHIR::STU3::DataElement resource.element.each do |e| @@ -1228,7 +1231,7 @@ def self.apply_invariants!(resource) when FHIR::STU3::DocumentManifest resource.content.each do |c| c.pAttachment = nil - c.pReference = textonly_reference('Any', FHIR::STU3) + c.pReference = textonly_reference('Any', namespace: FHIR::STU3) end when FHIR::STU3::DocumentReference resource.docStatus = 'preliminary' @@ -1322,7 +1325,7 @@ def self.apply_invariants!(resource) code = nil end resource.outcomeReference.each do |reference| - reference = textonly_reference('Observation', FHIR::STU3) + reference = textonly_reference('Observation', namespace: FHIR::STU3) end if resource.target && resource.target.dueDuration resource.target.dueDuration.system = 'http://unitsofmeasure.org' @@ -1363,14 +1366,14 @@ def self.apply_invariants!(resource) resource.doseQuantity.comparator = nil unless resource.doseQuantity.nil? if resource.notGiven unless resource.explanation.nil? - resource.explanation.reasonNotGiven = [ textonly_codeableconcept("reasonNotGiven #{SecureRandom.base64}", FHIR::STU3) ] + resource.explanation.reasonNotGiven = [ textonly_codeableconcept("reasonNotGiven #{SecureRandom.base64}", namespace: FHIR::STU3) ] resource.explanation.reason = nil end resource.reaction = nil else unless resource.explanation.nil? resource.explanation.reasonNotGiven = nil - resource.explanation.reason = [ textonly_codeableconcept("reason #{SecureRandom.base64}", FHIR::STU3) ] + resource.explanation.reason = [ textonly_codeableconcept("reason #{SecureRandom.base64}", namespace: FHIR::STU3) ] end end resource.status = ['completed','entered-in-error'].sample @@ -1379,7 +1382,7 @@ def self.apply_invariants!(resource) resource.package.each do |package| package.resource.each do |r| r.sourceUri = nil - r.sourceReference = textonly_reference('Any', FHIR::STU3) + r.sourceReference = textonly_reference('Any', namespace: FHIR::STU3) end end when FHIR::STU3::Linkage @@ -1423,26 +1426,26 @@ def self.apply_invariants!(resource) else resource.reasonNotGiven = nil end - resource.medicationReference = textonly_reference('Medication', FHIR::STU3) + resource.medicationReference = textonly_reference('Medication', namespace: FHIR::STU3) resource.medicationCodeableConcept = nil unless resource.dosage.nil? resource.dosage.dose.comparator = nil unless resource.dosage.dose.nil? resource.dosage.rateQuantity = nil end when FHIR::STU3::MedicationDispense - resource.medicationReference = textonly_reference('Medication', FHIR::STU3) + resource.medicationReference = textonly_reference('Medication', namespace: FHIR::STU3) resource.medicationCodeableConcept = nil resource.dosageInstruction.each {|d|d.timing = nil } resource.quantity.comparator = nil unless resource.quantity.nil? resource.daysSupply.comparator = nil unless resource.daysSupply.nil? when FHIR::STU3::MedicationRequest - resource.medicationReference = textonly_reference('Medication', FHIR::STU3) + resource.medicationReference = textonly_reference('Medication', namespace: FHIR::STU3) resource.medicationCodeableConcept = nil resource.dosageInstruction.each {|d|d.timing = nil } resource.dispenseRequest.quantity.comparator = nil if resource&.dispenseRequest&.quantity != nil when FHIR::STU3::MedicationStatement resource.reasonNotTaken = nil unless resource.taken == 'n' - resource.medicationReference = textonly_reference('Medication', FHIR::STU3) + resource.medicationReference = textonly_reference('Medication', namespace: FHIR::STU3) resource.medicationCodeableConcept = nil resource.dosage.each{|d|d.timing=nil} when FHIR::STU3::MessageDefinition @@ -1501,7 +1504,7 @@ def self.apply_invariants!(resource) p.searchType = nil unless p.type == 'string' end when FHIR::STU3::Patient - resource.maritalStatus = minimal_codeableconcept('http://hl7.org/fhir/v3/MaritalStatus','S', FHIR::STU3) + resource.maritalStatus = minimal_codeableconcept('http://hl7.org/fhir/v3/MaritalStatus','S', namespace: FHIR::STU3) when FHIR::STU3::PlanDefinition resource.action.each do |a| a.action.each do |b| @@ -1512,7 +1515,7 @@ def self.apply_invariants!(resource) resource.notDoneReason = nil if resource.notDone != true resource.focalDevice.each do |fd| code = ['implanted', 'explanted', 'manipulated'].sample - fd.action = minimal_codeableconcept('http://hl7.org/fhir/device-action', code, FHIR::STU3) + fd.action = minimal_codeableconcept('http://hl7.org/fhir/device-action', code, namespace: FHIR::STU3) end when FHIR::STU3::Provenance resource.entity.each do |e| @@ -1526,7 +1529,7 @@ def self.apply_invariants!(resource) end end when FHIR::STU3::RelatedPerson - resource.relationship = minimal_codeableconcept('http://hl7.org/fhir/patient-contact-relationship','family', FHIR::STU3) + resource.relationship = minimal_codeableconcept('http://hl7.org/fhir/patient-contact-relationship','family', namespace: FHIR::STU3) when FHIR::STU3::Questionnaire resource.item.each do |i| i.required = true @@ -1581,7 +1584,7 @@ def self.apply_invariants!(resource) when FHIR::STU3::SampledData resource.origin.comparator = nil unless resource.origin.nil? when FHIR::STU3::Signature - resource.type = [ minimal_coding('urn:iso-astm:E1762-95:2013','1.2.840.10065.1.12.1.18', FHIR::STU3) ] + resource.type = [ minimal_coding('urn:iso-astm:E1762-95:2013','1.2.840.10065.1.12.1.18', namespace: FHIR::STU3) ] resource.whoUri = 'http://projectcrucible.org' resource.whoReference = nil when FHIR::STU3::Specimen @@ -1601,10 +1604,10 @@ def self.apply_invariants!(resource) instance.quantity.comparator = nil unless instance.quantity.nil? end when FHIR::STU3::SupplyDelivery - resource.type = minimal_codeableconcept('http://hl7.org/fhir/supply-item-type','medication', FHIR::STU3) + resource.type = minimal_codeableconcept('http://hl7.org/fhir/supply-item-type','medication', namespace: FHIR::STU3) resource.suppliedItem.quantity.comparator = nil if !resource.suppliedItem.nil? && !resource.suppliedItem.quantity.nil? when FHIR::STU3::SupplyRequest - resource.category = minimal_codeableconcept('http://hl7.org/fhir/supply-kind','central', FHIR::STU3) + resource.category = minimal_codeableconcept('http://hl7.org/fhir/supply-kind','central', namespace: FHIR::STU3) when FHIR::STU3::StructureDefinition resource.derivation = 'constraint' resource.fhirVersion = 'STU3' @@ -1763,10 +1766,10 @@ def self.apply_invariants!(resource) # DSTU2 when FHIR::DSTU2::Appointment - resource.reason = nil # minimal_codeableconcept('http://snomed.info/sct','219006', FHIR::DSTU2) # drinker of alcohol - resource.participant.each{|p| p.type=[ minimal_codeableconcept('http://hl7.org/fhir/participant-type','emergency', FHIR::DSTU2) ] } + resource.reason = nil # minimal_codeableconcept('http://snomed.info/sct','219006', namespace: FHIR::DSTU2) # drinker of alcohol + resource.participant.each{|p| p.type=[ minimal_codeableconcept('http://hl7.org/fhir/participant-type','emergency', namespace: FHIR::DSTU2) ] } when FHIR::DSTU2::AppointmentResponse - resource.participantType = [ minimal_codeableconcept('http://hl7.org/fhir/participant-type','emergency', FHIR::DSTU2) ] + resource.participantType = [ minimal_codeableconcept('http://hl7.org/fhir/participant-type','emergency', namespace: FHIR::DSTU2) ] when FHIR::DSTU2::AuditEvent resource.object.each do |o| o.query=nil @@ -1806,35 +1809,35 @@ def self.apply_invariants!(resource) end when FHIR::DSTU2::Claim resource.item.each do |item| - item.type = minimal_coding('http://hl7.org/fhir/v3/ActCode','OHSINV', FHIR::DSTU2) + item.type = minimal_coding('http://hl7.org/fhir/v3/ActCode','OHSINV', namespace: FHIR::DSTU2) item.quantity.comparator = nil unless item.quantity.nil? item.detail.each do |detail| - detail.type = minimal_coding('http://hl7.org/fhir/v3/ActCode','OHSINV', FHIR::DSTU2) + detail.type = minimal_coding('http://hl7.org/fhir/v3/ActCode','OHSINV', namespace: FHIR::DSTU2) detail.quantity.comparator = nil unless detail.quantity.nil? detail.subDetail.each do |sub| - sub.type = minimal_coding('http://hl7.org/fhir/v3/ActCode','OHSINV', FHIR::DSTU2) - sub.service = minimal_coding('http://hl7.org/fhir/ex-USCLS','1205', FHIR::DSTU2) + sub.type = minimal_coding('http://hl7.org/fhir/v3/ActCode','OHSINV', namespace: FHIR::DSTU2) + sub.service = minimal_coding('http://hl7.org/fhir/ex-USCLS','1205', namespace: FHIR::DSTU2) sub.quantity.comparator = nil unless sub.quantity.nil? end end end resource.missingTeeth.each do |mt| - mt.tooth = minimal_coding('http://hl7.org/fhir/ex-fdi','42', FHIR::DSTU2) + mt.tooth = minimal_coding('http://hl7.org/fhir/ex-fdi','42', namespace: FHIR::DSTU2) end when FHIR::DSTU2::ClaimResponse resource.item.each do |item| - item.adjudication.each{|a|a.code = minimal_coding('http://hl7.org/fhir/adjudication','benefit', FHIR::DSTU2)} + item.adjudication.each{|a|a.code = minimal_coding('http://hl7.org/fhir/adjudication','benefit', namespace: FHIR::DSTU2)} item.detail.each do |detail| - detail.adjudication.each{|a|a.code = minimal_coding('http://hl7.org/fhir/adjudication','benefit', FHIR::DSTU2)} + detail.adjudication.each{|a|a.code = minimal_coding('http://hl7.org/fhir/adjudication','benefit', namespace: FHIR::DSTU2)} detail.subDetail.each do |sub| - sub.adjudication.each{|a|a.code = minimal_coding('http://hl7.org/fhir/adjudication','benefit', FHIR::DSTU2)} + sub.adjudication.each{|a|a.code = minimal_coding('http://hl7.org/fhir/adjudication','benefit', namespace: FHIR::DSTU2)} end end end resource.addItem.each do |addItem| - addItem.adjudication.each{|a|a.code = minimal_coding('http://hl7.org/fhir/adjudication','benefit', FHIR::DSTU2)} + addItem.adjudication.each{|a|a.code = minimal_coding('http://hl7.org/fhir/adjudication','benefit', namespace: FHIR::DSTU2)} addItem.detail.each do |detail| - detail.adjudication.each{|a|a.code = minimal_coding('http://hl7.org/fhir/adjudication','benefit', FHIR::DSTU2)} + detail.adjudication.each{|a|a.code = minimal_coding('http://hl7.org/fhir/adjudication','benefit', namespace: FHIR::DSTU2)} end end when FHIR::DSTU2::Communication @@ -1852,10 +1855,10 @@ def self.apply_invariants!(resource) end when FHIR::DSTU2::ConceptMap if(resource.sourceUri.nil? && resource.sourceReference.nil?) - resource.sourceReference = textonly_reference('ValueSet', FHIR::DSTU2) + resource.sourceReference = textonly_reference('ValueSet', namespace: FHIR::DSTU2) end if(resource.targetUri.nil? && resource.targetReference.nil?) - resource.targetReference = textonly_reference('ValueSet', FHIR::DSTU2) + resource.targetReference = textonly_reference('ValueSet', namespace: FHIR::DSTU2) end when FHIR::DSTU2::Conformance resource.fhirVersion = 'DSTU2' @@ -1869,18 +1872,18 @@ def self.apply_invariants!(resource) resource.messaging.each{|m| m.endpoint = nil} if resource.kind != 'instance' when FHIR::DSTU2::Contract resource.actor.each do |actor| - actor.entity = textonly_reference('Patient', FHIR::DSTU2) + actor.entity = textonly_reference('Patient', namespace: FHIR::DSTU2) end resource.valuedItem.each do |valuedItem| valuedItem.quantity.comparator = nil unless valuedItem.quantity.nil? end resource.term.each do |term| term.actor.each do |actor| - actor.entity = textonly_reference('Organization', FHIR::DSTU2) + actor.entity = textonly_reference('Organization', namespace: FHIR::DSTU2) end term.group.each do |group| group.actor.each do |actor| - actor.entity = textonly_reference('Organization', FHIR::DSTU2) + actor.entity = textonly_reference('Organization', namespace: FHIR::DSTU2) end end term.valuedItem.each do |valuedItem| @@ -1889,15 +1892,15 @@ def self.apply_invariants!(resource) end resource.friendly.each do |f| f.contentAttachment = nil - f.contentReference = textonly_reference('DocumentReference', FHIR::DSTU2) + f.contentReference = textonly_reference('DocumentReference', namespace: FHIR::DSTU2) end resource.legal.each do |f| f.contentAttachment = nil - f.contentReference = textonly_reference('DocumentReference', FHIR::DSTU2) + f.contentReference = textonly_reference('DocumentReference', namespace: FHIR::DSTU2) end resource.rule.each do |f| f.contentAttachment = nil - f.contentReference = textonly_reference('DocumentReference', FHIR::DSTU2) + f.contentReference = textonly_reference('DocumentReference', namespace: FHIR::DSTU2) end when FHIR::DSTU2::DataElement resource.mapping.each do |m| @@ -1920,10 +1923,10 @@ def self.apply_invariants!(resource) when FHIR::DSTU2::DocumentManifest resource.content.each do |c| c.pAttachment = nil - c.pReference = textonly_reference('Any', FHIR::DSTU2) + c.pReference = textonly_reference('Any', namespace: FHIR::DSTU2) end when FHIR::DSTU2::DocumentReference - resource.docStatus = minimal_codeableconcept('http://hl7.org/fhir/composition-status','preliminary', FHIR::DSTU2) + resource.docStatus = minimal_codeableconcept('http://hl7.org/fhir/composition-status','preliminary', namespace: FHIR::DSTU2) when FHIR::DSTU2::ElementDefinition keys = [] resource.constraint.each do |constraint| @@ -1947,7 +1950,7 @@ def self.apply_invariants!(resource) when FHIR::DSTU2::Goal resource.outcome.each do |outcome| outcome.resultCodeableConcept = nil - outcome.resultReference = textonly_reference('Observation', FHIR::DSTU2) + outcome.resultReference = textonly_reference('Observation', namespace: FHIR::DSTU2) end when FHIR::DSTU2::Group resource.member = [] if resource.actual==false @@ -1972,7 +1975,7 @@ def self.apply_invariants!(resource) # resource.uid = random_oid # index = SecureRandom.random_number(FHIR::DSTU2::ImagingObjectSelection::VALID_CODES[:title].length) # code = FHIR::DSTU2::ImagingObjectSelection::VALID_CODES[:title][index] - # resource.title = minimal_codeableconcept('http://nema.org/dicom/dicm',code, FHIR::DSTU2) + # resource.title = minimal_codeableconcept('http://nema.org/dicom/dicm',code, namespace: FHIR::DSTU2) # resource.study.each do |study| # study.uid = random_oid # study.series.each do |series| @@ -1995,13 +1998,13 @@ def self.apply_invariants!(resource) when FHIR::DSTU2::Immunization if resource.wasNotGiven resource.explanation = FHIR::DSTU2::Immunization::Explanation.new unless resource.explanation - resource.explanation.reasonNotGiven = [ textonly_codeableconcept("reasonNotGiven #{SecureRandom.base64}", FHIR::DSTU2) ] + resource.explanation.reasonNotGiven = [ textonly_codeableconcept("reasonNotGiven #{SecureRandom.base64}", namespace: FHIR::DSTU2) ] resource.explanation.reason = nil resource.reaction = nil else resource.explanation = FHIR::DSTU2::Immunization::Explanation.new unless resource.explanation resource.explanation.reasonNotGiven = nil - resource.explanation.reason = [ textonly_codeableconcept("reason #{SecureRandom.base64}", FHIR::DSTU2) ] + resource.explanation.reason = [ textonly_codeableconcept("reason #{SecureRandom.base64}", namespace: FHIR::DSTU2) ] end resource.doseQuantity.comparator = nil unless resource.doseQuantity.nil? when FHIR::DSTU2::ImplementationGuide @@ -2009,7 +2012,7 @@ def self.apply_invariants!(resource) resource.package.each do |package| package.resource.each do |r| r.sourceUri = nil - r.sourceReference = textonly_reference('Any', FHIR::DSTU2) + r.sourceReference = textonly_reference('Any', namespace: FHIR::DSTU2) end end when FHIR::DSTU2::List @@ -2045,20 +2048,20 @@ def self.apply_invariants!(resource) else resource.reasonNotGiven = nil end - resource.medicationReference = textonly_reference('Medication', FHIR::DSTU2) + resource.medicationReference = textonly_reference('Medication', namespace: FHIR::DSTU2) resource.medicationCodeableConcept = nil resource.dosage.quantity.comparator = nil if !resource&.dosage&.quantity.nil? when FHIR::DSTU2::MedicationDispense - resource.medicationReference = textonly_reference('Medication', FHIR::DSTU2) + resource.medicationReference = textonly_reference('Medication', namespace: FHIR::DSTU2) resource.medicationCodeableConcept = nil resource.dosageInstruction.each {|d|d.timing = nil } when FHIR::DSTU2::MedicationOrder - resource.medicationReference = textonly_reference('Medication', FHIR::DSTU2) + resource.medicationReference = textonly_reference('Medication', namespace: FHIR::DSTU2) resource.medicationCodeableConcept = nil resource.dosageInstruction.each {|d|d.timing = nil } when FHIR::DSTU2::MedicationStatement resource.reasonNotTaken = nil if resource.wasNotTaken != true - resource.medicationReference = textonly_reference('Medication', FHIR::DSTU2) + resource.medicationReference = textonly_reference('Medication', namespace: FHIR::DSTU2) resource.medicationCodeableConcept = nil resource.dosage.each do |d| d.timing=nil @@ -2105,7 +2108,7 @@ def self.apply_invariants!(resource) when FHIR::DSTU2::Order resource.when.schedule = nil when FHIR::DSTU2::Patient - resource.maritalStatus = minimal_codeableconcept('http://hl7.org/fhir/v3/MaritalStatus','S', FHIR::DSTU2) + resource.maritalStatus = minimal_codeableconcept('http://hl7.org/fhir/v3/MaritalStatus','S', namespace: FHIR::DSTU2) resource.communication.each do |communication| communication.language.coding.each do |c| c.system = 'http://tools.ietf.org/html/bcp47' @@ -2115,7 +2118,7 @@ def self.apply_invariants!(resource) when FHIR::DSTU2::Procedure resource.reasonNotPerformed = nil if resource.notPerformed != true resource.focalDevice.each do |fd| - fd.action = minimal_codeableconcept('http://hl7.org/fhir/ValueSet/device-action','implanted', FHIR::DSTU2) + fd.action = minimal_codeableconcept('http://hl7.org/fhir/ValueSet/device-action','implanted', namespace: FHIR::DSTU2) end when FHIR::DSTU2::Provenance resource.entity.each do |e| @@ -2129,7 +2132,7 @@ def self.apply_invariants!(resource) end end when FHIR::DSTU2::RelatedPerson - resource.relationship = minimal_codeableconcept('http://hl7.org/fhir/patient-contact-relationship','family', FHIR::DSTU2) + resource.relationship = minimal_codeableconcept('http://hl7.org/fhir/patient-contact-relationship','family', namespace: FHIR::DSTU2) when FHIR::DSTU2::Questionnaire resource.group.required = true resource.group.group = nil @@ -2142,13 +2145,13 @@ def self.apply_invariants!(resource) resource.channel.payload = 'applicaton/json+fhir' resource.end = nil when FHIR::DSTU2::SupplyDelivery - resource.type = minimal_codeableconcept('http://hl7.org/fhir/supply-item-type','medication', FHIR::DSTU2) + resource.type = minimal_codeableconcept('http://hl7.org/fhir/supply-item-type','medication', namespace: FHIR::DSTU2) resource.quantity.comparator = nil unless resource.quantity.nil? when FHIR::DSTU2::SupplyRequest - resource.kind = minimal_codeableconcept('http://hl7.org/fhir/supply-kind','central', FHIR::DSTU2) + resource.kind = minimal_codeableconcept('http://hl7.org/fhir/supply-kind','central', namespace: FHIR::DSTU2) if resource.when resource.when.schedule = nil - resource.when.code = minimal_codeableconcept('http://snomed.info/sct','20050000', FHIR::DSTU2) #biweekly + resource.when.code = minimal_codeableconcept('http://snomed.info/sct','20050000', namespace: FHIR::DSTU2) #biweekly end when FHIR::DSTU2::StructureDefinition resource.fhirVersion = 'DSTU2' diff --git a/lib/tasks/tasks.rake b/lib/tasks/tasks.rake index e4f8ed08..d48c20f5 100644 --- a/lib/tasks/tasks.rake +++ b/lib/tasks/tasks.rake @@ -36,8 +36,7 @@ namespace :crucible do require 'benchmark' result = {} b = Benchmark.measure { - client = FHIR::Client.new(args.url) - client.use_fhir_version(fhir_version) + client = FHIR::Client.new(args.url, fhir_version: fhir_version) client.setup_security result = execute_all(args.url, client, args.output) } @@ -47,11 +46,12 @@ namespace :crucible do end desc 'execute all test scripts' - task :execute_all_testscripts, [:url, :output] do |t, args| + task :execute_all_testscripts, [:url, :fhir_version, :output] do |t, args| FHIR.logger = Logger.new("logs/plan_executor.log", 10, 1024000) + fhir_version = resolve_testscript_fhir_version(args.fhir_version) require 'benchmark' b = Benchmark.measure { - client = FHIR::Client.new(args.url) + client = FHIR::Client.new(args.url, fhir_version: fhir_version) client.setup_security results = Crucible::Tests::TestScriptEngine.new(client).execute_all process_results(results, args.url, args.output) @@ -60,11 +60,12 @@ namespace :crucible do end desc 'execute testscript and get testreport' - task :testreport, [:url, :test, :filename] do |t, args| + task :testreport, [:url, :fhir_version, :test, :filename] do |t, args| FHIR.logger = Logger.new("logs/plan_executor.log", 10, 1024000) + fhir_version = resolve_testscript_fhir_version(args.fhir_version) require 'benchmark' b = Benchmark.measure { - client = FHIR::Client.new(args.url) + client = FHIR::Client.new(args.url, fhir_version: fhir_version) client.setup_security engine = Crucible::Tests::TestScriptEngine.new(client) script = engine.find_test(args.test) @@ -90,8 +91,7 @@ namespace :crucible do require 'benchmark' result = {} b = Benchmark.measure { - client = FHIR::Client.new(args.url) - client.use_fhir_version(fhir_version) + client = FHIR::Client.new(args.url, fhir_version: fhir_version) client.setup_security result = execute_test(args.url, client, args.test, args.resource, args.output) } @@ -147,6 +147,14 @@ namespace :crucible do Crucible::FHIRVersion.resolve(version_string) end + def resolve_testscript_fhir_version(version_string) + version = resolve_fhir_version(version_string) + return version if version == :stu3 + + raise Crucible::FHIRVersion::UnsupportedVersionError, + "FHIR TestScripts require STU3, got #{version}" + end + def execute_test(url, client, key, resourceType=nil, output=nil) executor = Crucible::Tests::Executor.new(client) test = executor.find_test(key) @@ -347,8 +355,7 @@ namespace :crucible do puts "## #{url}" puts "```" b = Benchmark.measure { - client = FHIR::Client.new(url) - client.use_fhir_version(fhir_version) + client = FHIR::Client.new(url, fhir_version: fhir_version) client.setup_security execute_test(url, client, args.test, args.resource_type, args.output) } @@ -374,8 +381,7 @@ namespace :crucible do puts "## #{url}" puts "```" b = Benchmark.measure { - client = FHIR::Client.new(url) - client.use_fhir_version(fhir_version) + client = FHIR::Client.new(url, fhir_version: fhir_version) client.setup_security results = execute_all(url, client, output) } @@ -450,8 +456,7 @@ namespace :crucible do end end - client = FHIR::Client.new(args.url) - client.use_fhir_version(fhir_version) + client = FHIR::Client.new(args.url, fhir_version: fhir_version) client.setup_security client.monitor_requirements test = args.test.to_sym diff --git a/lib/tests/base_test.rb b/lib/tests/base_test.rb index 7bb8290e..dd905d06 100644 --- a/lib/tests/base_test.rb +++ b/lib/tests/base_test.rb @@ -51,7 +51,9 @@ def initialize(client, client2=nil) end def version_namespace - Crucible::FHIRVersion.namespace(@client&.fhir_version) + raise ArgumentError, 'A versioned FHIR client is required' unless @client + + Crucible::FHIRVersion.namespace(@client.fhir_version) end def multiserver diff --git a/lib/tests/suites/base_suite.rb b/lib/tests/suites/base_suite.rb index 3e2d9f56..5cc4cb7c 100644 --- a/lib/tests/suites/base_suite.rb +++ b/lib/tests/suites/base_suite.rb @@ -32,11 +32,9 @@ def build_messages(operation_outcome) # move to another area? # also, this may be causing a problem on the fhir starburst structure def fhir_version - if @client.nil? - :r4 - else - @client.fhir_version - end + raise ArgumentError, 'A versioned FHIR client is required' unless @client + + @client.fhir_version end def get_resource(resource) @@ -60,7 +58,7 @@ def self.valid_resource?(fhir_version, resource) Crucible::FHIRVersion.namespace(fhir_version).const_get(:RESOURCES).include?(resource.to_s) end - def self.fhir_resources(fhir_version=nil) + def self.fhir_resources(fhir_version) namespace = Crucible::FHIRVersion.namespace(fhir_version) namespace.const_get(:RESOURCES) .reject { |resource| EXCLUDED_RESOURCES.include?(resource) } diff --git a/lib/tests/suites/connectathon_terminology_track.rb b/lib/tests/suites/connectathon_terminology_track.rb index 100f22b0..c7d2cd35 100644 --- a/lib/tests/suites/connectathon_terminology_track.rb +++ b/lib/tests/suites/connectathon_terminology_track.rb @@ -212,7 +212,7 @@ def teardown validates resource: 'ValueSet', methods: ['create'] } - @resources = Crucible::Generator::Resources.new + @resources = Crucible::Generator::Resources.new(fhir_version) @codesystem_simple = @resources.codesystem_simple @valueset_simple = @resources.valueset_simple @@ -325,7 +325,7 @@ def teardown validates resource: 'ConceptMap', methods: ['create'] } - @resources = Crucible::Generator::Resources.new + @resources = Crucible::Generator::Resources.new(fhir_version) @conceptmap_simple = @resources.conceptmap_simple @conceptmap_simple.id = nil @conceptmap_simple.url = @conceptmap_simple.url + rand(10000000).to_s diff --git a/lib/tests/suites/format_test.rb b/lib/tests/suites/format_test.rb index ffc05b84..1cf27605 100644 --- a/lib/tests/suites/format_test.rb +++ b/lib/tests/suites/format_test.rb @@ -17,7 +17,7 @@ def description def initialize(client1, client2=nil) super(client1, client2) @supported_versions = [:dstu2, :stu3, :r4, :r4b] - if fhir_version == :dstu2 + if client1&.fhir_version == :dstu2 @xml_format = FHIR::Formats::ResourceFormat::RESOURCE_XML_DSTU2 @json_format = FHIR::Formats::ResourceFormat::RESOURCE_JSON_DSTU2 else diff --git a/lib/tests/suites/incendi_consent_search_by_patient_reference_329.rb b/lib/tests/suites/incendi_consent_search_by_patient_reference_329.rb index 11395657..29f91caf 100644 --- a/lib/tests/suites/incendi_consent_search_by_patient_reference_329.rb +++ b/lib/tests/suites/incendi_consent_search_by_patient_reference_329.rb @@ -19,7 +19,7 @@ def initialize(client1, client2 = nil) def setup - @patient = ResourceGenerator.minimal_patient(nil, nil, version_namespace) + @patient = ResourceGenerator.minimal_patient(nil, nil, namespace: version_namespace) reply = @client.create(@patient) assert_response_ok(reply) @patient_id = reply.id diff --git a/lib/tests/suites/incendi_elements_search_parameter.rb b/lib/tests/suites/incendi_elements_search_parameter.rb index 15416b9b..ec7cb428 100644 --- a/lib/tests/suites/incendi_elements_search_parameter.rb +++ b/lib/tests/suites/incendi_elements_search_parameter.rb @@ -18,7 +18,7 @@ def initialize(client1, client2 = nil) end def setup - @patient = ResourceGenerator.minimal_patient('elements-search-parameter', 'Elements', version_namespace) + @patient = ResourceGenerator.minimal_patient('elements-search-parameter', 'Elements', namespace: version_namespace) @patient.gender = 'male' @patient.birthDate = '1974-12-25' diff --git a/lib/tests/suites/search_test_robust.rb b/lib/tests/suites/search_test_robust.rb index cee555e3..d3947318 100644 --- a/lib/tests/suites/search_test_robust.rb +++ b/lib/tests/suites/search_test_robust.rb @@ -39,7 +39,7 @@ def teardown skip 'TODO: https://github.com/FirelyTeam/spark/issues/310' - match_patient = Crucible::Generator::Resources.new.minimal_patient + match_patient = Crucible::Generator::Resources.new(fhir_version).minimal_patient match_patient.identifier = nil reply = @client.match(match_patient) assert_response_ok(reply) diff --git a/lib/tests/suites/suite_engine.rb b/lib/tests/suites/suite_engine.rb index f60a3b3b..72927c02 100644 --- a/lib/tests/suites/suite_engine.rb +++ b/lib/tests/suites/suite_engine.rb @@ -81,7 +81,8 @@ def build_suites_map end end - def self.generate_metadata + def self.generate_metadata(fhir_version) + version = Crucible::FHIRVersion.resolve(fhir_version) metadata = {} puts "---" puts "BUILDING METADATA" @@ -89,8 +90,8 @@ def self.generate_metadata SuiteEngine.new.tests.each do |test| test_file = Crucible::Tests.const_get(test).new(nil) if test_file.respond_to? 'resource_class=' - Crucible::Tests::BaseSuite.fhir_resources.each do |klass| - test_file.resource_class = Module.const_get("FHIR::#{klass}") + Crucible::Tests::BaseSuite.fhir_resources(version).each do |klass| + test_file.resource_class = klass puts "---" puts "BUILDING METADATA - #{test}#{klass}" puts "---" diff --git a/lib/tests/suites/transaction_test.rb b/lib/tests/suites/transaction_test.rb index 3939a89d..3e427d32 100644 --- a/lib/tests/suites/transaction_test.rb +++ b/lib/tests/suites/transaction_test.rb @@ -64,18 +64,24 @@ def teardown validates resource: nil, methods: ['transaction-system'] } - @patient0 = ResourceGenerator.minimal_patient("#{Time.now.to_i}",'Transaction', version_namespace) + @patient0 = ResourceGenerator.minimal_patient("#{Time.now.to_i}", 'Transaction', namespace: version_namespace) patient0_id = SecureRandom.uuid patient0_uri = "urn:uuid:#{patient0_id}" # height - @obs0a = ResourceGenerator.minimal_observation('http://loinc.org','8302-2',170,'cm',patient0_id) + @obs0a = ResourceGenerator.minimal_observation('http://loinc.org', '8302-2', 170, 'cm', patient0_id, namespace: version_namespace) @obs0a.subject.reference = patient0_uri # weight - @obs0b = ResourceGenerator.minimal_observation('http://loinc.org','3141-9',200,'kg',patient0_id) + @obs0b = ResourceGenerator.minimal_observation('http://loinc.org', '3141-9', 200, 'kg', patient0_id, namespace: version_namespace) @obs0b.subject.reference = patient0_uri # obesity - @condition0 = ResourceGenerator.minimal_condition('http://snomed.info/sct','414915002',patient0_id, version_namespace, patient0_uri) + @condition0 = ResourceGenerator.minimal_condition( + 'http://snomed.info/sct', + '414915002', + patient0_id, + namespace: version_namespace, + patient_ref: patient0_uri + ) @client.begin_transaction @client.add_transaction_request('POST',nil,@patient0).fullUrl = patient0_uri @@ -135,7 +141,7 @@ def teardown assert @created_patient_record, 'Could not create patient in XFER0.' # patient has gained weight - @obs1 = ResourceGenerator.minimal_observation('http://loinc.org','3141-9',250,'kg',@patient0.id, version_namespace) + @obs1 = ResourceGenerator.minimal_observation('http://loinc.org', '3141-9', 250, 'kg', @patient0.id, namespace: version_namespace) @client.begin_transaction @client.add_transaction_request('POST',nil,@patient0,"identifier=#{@patient0.identifier.first.system}|#{@patient0.identifier.first.value}").fullUrl = "urn:uuid:#{SecureRandom.uuid}" @@ -171,7 +177,7 @@ def teardown assert @created_patient_record, 'Could not create patient in XFER0.' # weight - @obs2 = ResourceGenerator.minimal_observation('http://loinc.org','3141-9',100,'kg',@patient0.id, version_namespace) + @obs2 = ResourceGenerator.minimal_observation('http://loinc.org', '3141-9', 100, 'kg', @patient0.id, namespace: version_namespace) # obesity has been refuted if fhir_version == :dstu2 @condition0.patient.reference = "Patient/#{@patient0.id}" @@ -215,7 +221,7 @@ def teardown } assert @created_patient_record, 'Could not create patient in XFER0.' - @patient1 = ResourceGenerator.minimal_patient(@patient0.identifier.first.value,@patient0.name.first.given.first, version_namespace) + @patient1 = ResourceGenerator.minimal_patient(@patient0.identifier.first.value, @patient0.name.first.given.first, namespace: version_namespace) reply = @client.create @patient1 assert_response_ok(reply) @patient1.id = (reply.resource.try(:id) || reply.id) @@ -254,9 +260,9 @@ def teardown assert @created_patient_record, 'Could not create patient in XFER0.' # height observation - @obs3 = ResourceGenerator.minimal_observation('http://loinc.org','8302-2',177,'cm',@patient0.id, version_namespace) + @obs3 = ResourceGenerator.minimal_observation('http://loinc.org', '8302-2', 177, 'cm', @patient0.id, namespace: version_namespace) # weight observation - @obs4 = ResourceGenerator.minimal_observation('http://loinc.org','3141-9',105,'kg',@patient0.id, version_namespace) + @obs4 = ResourceGenerator.minimal_observation('http://loinc.org', '3141-9', 105, 'kg', @patient0.id, namespace: version_namespace) # give this *weight* observation the ID of the *height* observation created in XFER1 @obs4.id = @obs0a.id @@ -436,10 +442,10 @@ def teardown skip 'TODO: https://github.com/FirelyTeam/spark/issues/306' - @batch_patient = ResourceGenerator.minimal_patient("#{Time.now.to_i}",'Batch', version_namespace) + @batch_patient = ResourceGenerator.minimal_patient("#{Time.now.to_i}", 'Batch', namespace: version_namespace) @batch_patient_id = "urn:uuid:#{SecureRandom.uuid}" # assign an id so related resources can reference the patient # height - @batch_obs = ResourceGenerator.minimal_observation('http://loinc.org','8302-2',900,'cm',@batch_patient_id, version_namespace) + @batch_obs = ResourceGenerator.minimal_observation('http://loinc.org', '8302-2', 900, 'cm', @batch_patient_id, namespace: version_namespace) @batch_obs.subject.reference = @batch_patient_id @client.begin_batch @@ -479,15 +485,15 @@ def teardown skip 'TODO: https://github.com/FirelyTeam/spark/issues/305' - @batch_patient_2 = ResourceGenerator.minimal_patient("#{Time.now.to_i}",'Batch', version_namespace) + @batch_patient_2 = ResourceGenerator.minimal_patient("#{Time.now.to_i}", 'Batch', namespace: version_namespace) reply = @client.create @batch_patient_2 assert_response_ok(reply) @batch_patient_2.id = (reply.resource.try(:id) || reply.id) # height - @batch_obs_2 = ResourceGenerator.minimal_observation('http://loinc.org','8302-2',300,'cm',@batch_patient_2.id, version_namespace) + @batch_obs_2 = ResourceGenerator.minimal_observation('http://loinc.org', '8302-2', 300, 'cm', @batch_patient_2.id, namespace: version_namespace) # weight - @batch_obs_3 = ResourceGenerator.minimal_observation('http://loinc.org','3141-9',500,'kg',@batch_patient_2.id, version_namespace) + @batch_obs_3 = ResourceGenerator.minimal_observation('http://loinc.org', '3141-9', 500, 'kg', @batch_patient_2.id, namespace: version_namespace) @client.begin_batch @client.add_batch_request('POST',nil,@batch_obs_2).fullUrl = "urn:uuid:#{SecureRandom.uuid}" @@ -554,7 +560,7 @@ def teardown } # Create a Patient and capture the ETag from the response - @ifmatch_patient = ResourceGenerator.minimal_patient("#{Time.now.to_i}",'IfMatch', version_namespace) + @ifmatch_patient = ResourceGenerator.minimal_patient("#{Time.now.to_i}", 'IfMatch', namespace: version_namespace) reply = @client.create @ifmatch_patient assert_response_ok(reply) @ifmatch_patient.id = (reply.resource.try(:id) || reply.id) @@ -587,7 +593,7 @@ def teardown } # Create a Patient - @ifmatch_patient_2 = ResourceGenerator.minimal_patient("#{Time.now.to_i}",'IfMatch2', version_namespace) + @ifmatch_patient_2 = ResourceGenerator.minimal_patient("#{Time.now.to_i}", 'IfMatch2', namespace: version_namespace) reply = @client.create @ifmatch_patient_2 assert_response_ok(reply) @ifmatch_patient_2.id = (reply.resource.try(:id) || reply.id) diff --git a/lib/tests/testscripts/base_testscript.rb b/lib/tests/testscripts/base_testscript.rb index 7ce9cf7a..88590b1b 100644 --- a/lib/tests/testscripts/base_testscript.rb +++ b/lib/tests/testscripts/base_testscript.rb @@ -78,6 +78,14 @@ def initialize(testscript, client, client2=nil) load_fixtures end + def version_namespace + if @client && @client.fhir_version != :stu3 + raise ArgumentError, "FHIR TestScripts require STU3, got #{@client.fhir_version}" + end + + FHIR::STU3 + end + def author @testscript.name end diff --git a/lib/tests/testscripts/testscript_engine.rb b/lib/tests/testscripts/testscript_engine.rb index 85a11af1..4eaa6a9b 100644 --- a/lib/tests/testscripts/testscript_engine.rb +++ b/lib/tests/testscripts/testscript_engine.rb @@ -8,7 +8,7 @@ def initialize(client=nil, client2=nil) @client = client @client2 = client2 @scripts = [] - load_testscripts if client&.fhir_version != :dstu2 # Run tests scripts on STU3+ only. + load_testscripts if client.nil? || client.fhir_version == :stu3 end def tests diff --git a/lib/uscore_resource_generator.rb b/lib/uscore_resource_generator.rb index 59bed11c..116400fb 100644 --- a/lib/uscore_resource_generator.rb +++ b/lib/uscore_resource_generator.rb @@ -6,7 +6,7 @@ class USCoreResourceGenerator < ResourceGenerator # If we add another version, we may need to update these def self.patient(identifier='0',name='Name') - resource = minimal_patient(identifier,name) + resource = minimal_patient(identifier, name, namespace: FHIR) # resource.identifier = [ minimal_identifier(identifier) ] # resource.name = [ minimal_humanname(name) ] resource.meta.profile = ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient'] @@ -17,7 +17,7 @@ def self.patient(identifier='0',name='Name') resource.birthDate = DateTime.now.strftime("%Y-%m-%d") resource.deceasedBoolean = false resource.address = [ address ] - resource.maritalStatus = minimal_codeableconcept('http://hl7.org/fhir/v3/MaritalStatus','S') + resource.maritalStatus = minimal_codeableconcept('http://hl7.org/fhir/v3/MaritalStatus', 'S', namespace: FHIR) resource.multipleBirthBoolean = false resource.contact = [ patient_contact ] resource.communication = [ patient_communication ] @@ -26,8 +26,8 @@ def self.patient(identifier='0',name='Name') resource.managingOrganization = FHIR::Reference.new # reference to US Core-Organization resource.managingOrganization.display = 'US Core Organization' resource.extension = [] - resource.extension << make_extension('http://hl7.org/fhir/us/core/StructureDefinition/us-core-race','CodeableConcept',minimal_codeableconcept('http://hl7.org/fhir/v3/Race','2106-3')) - resource.extension << make_extension('http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity','CodeableConcept',minimal_codeableconcept('http://hl7.org/fhir/v3/Ethnicity','2186-5')) + resource.extension << make_extension('http://hl7.org/fhir/us/core/StructureDefinition/us-core-race','CodeableConcept',minimal_codeableconcept('http://hl7.org/fhir/v3/Race', '2106-3', namespace: FHIR)) + resource.extension << make_extension('http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity','CodeableConcept',minimal_codeableconcept('http://hl7.org/fhir/v3/Ethnicity', '2186-5', namespace: FHIR)) resource.extension << make_extension('http://hl7.org/fhir/StructureDefinition/patient-mothersMaidenName','String','Liberty') resource.extension << make_extension('http://hl7.org/fhir/StructureDefinition/birthPlace','Address',address) resource @@ -70,8 +70,11 @@ def self.address def self.patient_contact resource = FHIR::Patient::Contact.new - resource.relationship = [ minimal_codeableconcept('http://hl7.org/fhir/patient-contact-relationship','parent'), minimal_codeableconcept('http://hl7.org/fhir/patient-contact-relationship','emergency') ] - resource.name = minimal_humanname('Mom') + resource.relationship = [ + minimal_codeableconcept('http://hl7.org/fhir/patient-contact-relationship', 'parent', namespace: FHIR), + minimal_codeableconcept('http://hl7.org/fhir/patient-contact-relationship', 'emergency', namespace: FHIR) + ] + resource.name = minimal_humanname('Mom', namespace: FHIR) resource.telecom = [ contact_point ] resource.address = address resource @@ -79,7 +82,7 @@ def self.patient_contact def self.patient_communication resource = FHIR::Patient::Communication.new - resource.language = minimal_codeableconcept('http://tools.ietf.org/html/bcp47','en-US') + resource.language = minimal_codeableconcept('http://tools.ietf.org/html/bcp47', 'en-US', namespace: FHIR) resource end diff --git a/test/unit/fhir_structure_test.rb b/test/unit/fhir_structure_test.rb index 81bf5cf2..4b0a86cc 100644 --- a/test/unit/fhir_structure_test.rb +++ b/test/unit/fhir_structure_test.rb @@ -31,7 +31,7 @@ def test_no_duplicate_names_in_starburst end end - def fhir_resources(fhir_version=nil) + def fhir_resources(fhir_version) Crucible::FHIRVersion.namespace(fhir_version).const_get(:RESOURCES) end diff --git a/test/unit/fhir_version_test.rb b/test/unit/fhir_version_test.rb index 6aa8ee7b..661737be 100644 --- a/test/unit/fhir_version_test.rb +++ b/test/unit/fhir_version_test.rb @@ -1,10 +1,18 @@ require_relative '../test_helper' class FHIRVersionTest < Test::Unit::TestCase - def test_omitted_version_defaults_to_r4 - assert_equal :r4, Crucible::FHIRVersion.resolve - assert_equal :r4, Crucible::FHIRVersion.resolve('') - assert_equal :r4, Crucible::FHIRVersion.resolve(' ') + def test_omitted_version_is_rejected + assert_raise(ArgumentError) do + Crucible::FHIRVersion.resolve + end + + [nil, '', ' '].each do |version| + error = assert_raise(Crucible::FHIRVersion::UnsupportedVersionError) do + Crucible::FHIRVersion.resolve(version) + end + + assert_match(/FHIR version is required/, error.message) + end end def test_known_versions_are_resolved_explicitly @@ -30,6 +38,7 @@ def test_model_classes_resolve_to_their_owning_version assert_equal :stu3, Crucible::FHIRVersion.for_class(FHIR::STU3::Patient) assert_equal :r4, Crucible::FHIRVersion.for_class(FHIR::Patient) assert_equal :r4b, Crucible::FHIRVersion.for_class(FHIR::R4B::Patient) + assert_equal :r4b, Crucible::FHIRVersion.for_class(FHIR::R4B::Patient.new) end def test_unknown_version_fails_instead_of_falling_back_to_r4 diff --git a/test/unit/format_suite_test.rb b/test/unit/format_suite_test.rb index f2f589c9..230e1e96 100644 --- a/test/unit/format_suite_test.rb +++ b/test/unit/format_suite_test.rb @@ -20,8 +20,7 @@ class FormatSuiteTest < Test::Unit::TestCase def execute_format_suite(version, specification_version) @namespace = Crucible::FHIRVersion.namespace(version) - @client = FHIR::Client.new(BASE_URL) - @client.use_fhir_version(version) + @client = FHIR::Client.new(BASE_URL, fhir_version: version) @created_patient = nil stub_capability_statement(specification_version) stub_create diff --git a/test/unit/metadata_test.rb b/test/unit/metadata_test.rb index c58db8d1..15b3c2d1 100644 --- a/test/unit/metadata_test.rb +++ b/test/unit/metadata_test.rb @@ -40,4 +40,10 @@ def test_testscript_find assert !keyed_test.nil?, "Failed to find testscript by key" end + def test_testscripts_are_not_loaded_for_non_stu3_clients + client = FHIR::Client.new('http://r4b', fhir_version: :r4b) + + assert_empty Crucible::Tests::TestScriptEngine.new(client).tests + end + end diff --git a/test/unit/r4b_routing_test.rb b/test/unit/r4b_routing_test.rb index 639ca1e2..4b42318b 100644 --- a/test/unit/r4b_routing_test.rb +++ b/test/unit/r4b_routing_test.rb @@ -2,8 +2,7 @@ class R4BRoutingTest < Test::Unit::TestCase def setup - @client = FHIR::Client.new('http://r4b') - @client.use_fhir_version(:r4b) + @client = FHIR::Client.new('http://r4b', fhir_version: :r4b) @suite = Crucible::Tests::BaseSuite.new(@client) end @@ -45,6 +44,54 @@ def test_resource_generator_uses_r4b_types assert_instance_of FHIR::R4B::Meta, patient.meta end + def test_minimal_resource_helpers_require_an_explicit_namespace + assert_raise(ArgumentError) do + Crucible::Tests::ResourceGenerator.minimal_patient + end + end + + def test_minimal_patient_keeps_all_children_in_r4b_namespace + patient = Crucible::Tests::ResourceGenerator.minimal_patient(namespace: FHIR::R4B) + + assert_instance_of FHIR::R4B::Patient, patient + assert_instance_of FHIR::R4B::Identifier, patient.identifier.first + assert_instance_of FHIR::R4B::HumanName, patient.name.first + assert_instance_of FHIR::R4B::Meta, patient.meta + assert_instance_of FHIR::R4B::Coding, patient.meta.tag.first + end + + def test_minimal_condition_uses_r4b_status_datatype + condition = Crucible::Tests::ResourceGenerator.minimal_condition(namespace: FHIR::R4B) + + assert_instance_of FHIR::R4B::Condition, condition + assert_instance_of FHIR::R4B::CodeableConcept, condition.verificationStatus + assert_instance_of FHIR::R4B::Coding, condition.verificationStatus.coding.first + assert_equal 'confirmed', condition.verificationStatus.coding.first.code + end + + def test_condition_status_conversion_preserves_each_version_namespace + r4_condition = FHIR::Condition.new + r4_condition.clinicalStatus = 'active' + r4_condition.verificationStatus = 'confirmed' + r4b_condition = FHIR::R4B::Condition.new + r4b_condition.clinicalStatus = 'active' + r4b_condition.verificationStatus = 'confirmed' + stu3_condition = FHIR::STU3::Condition.new + stu3_condition.clinicalStatus = 'active' + stu3_condition.verificationStatus = 'confirmed' + + Crucible::Tests::ResourceGenerator.fix_condition(r4_condition) + Crucible::Tests::ResourceGenerator.fix_condition(r4b_condition) + Crucible::Tests::ResourceGenerator.fix_condition(stu3_condition) + + assert_instance_of FHIR::CodeableConcept, r4_condition.clinicalStatus + assert_instance_of FHIR::CodeableConcept, r4_condition.verificationStatus + assert_instance_of FHIR::R4B::CodeableConcept, r4b_condition.clinicalStatus + assert_instance_of FHIR::R4B::CodeableConcept, r4b_condition.verificationStatus + assert_equal 'active', stu3_condition.clinicalStatus + assert_equal 'confirmed', stu3_condition.verificationStatus + end + def test_resource_fixture_helper_uses_r4b_namespace resources = Crucible::Generator::Resources.new(:r4b) From 49e713f2702c0657a7394a3328eaa87d74d903df Mon Sep 17 00:00:00 2001 From: Kenneth Myhra Date: Thu, 23 Jul 2026 22:14:08 +0200 Subject: [PATCH 10/16] Populate empty generated CodeableReference values --- lib/resource_generator.rb | 12 ++++++++++ test/unit/resource_generator_test.rb | 36 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/lib/resource_generator.rb b/lib/resource_generator.rb index f9d89321..93106247 100644 --- a/lib/resource_generator.rb +++ b/lib/resource_generator.rb @@ -312,6 +312,16 @@ def self.tag_metadata(resource, namespace:) resource end + def self.fix_codeable_reference(resource) + namespace = Crucible::FHIRVersion.namespace(Crucible::FHIRVersion.for_class(resource)) + return resource unless namespace.const_defined?(:CodeableReference, false) + return resource unless resource.is_a?(namespace.const_get(:CodeableReference)) + return resource if resource.concept || resource.reference + + resource.concept = textonly_codeableconcept('Generated CodeableReference', namespace: namespace) + resource + end + def self.fix_condition(resource) version = Crucible::FHIRVersion.for_class(resource) return resource unless [:r4, :r4b].include?(version) @@ -333,6 +343,8 @@ def self.fix_condition(resource) end def self.apply_invariants!(resource) + fix_codeable_reference(resource) + case resource when FHIR::ActivityDefinition resource.quantity.comparator = nil unless resource.quantity.nil? diff --git a/test/unit/resource_generator_test.rb b/test/unit/resource_generator_test.rb index b86b4231..bbe4abb4 100644 --- a/test/unit/resource_generator_test.rb +++ b/test/unit/resource_generator_test.rb @@ -39,6 +39,42 @@ class ResourceGeneratorTest < Test::Unit::TestCase end end + def test_empty_r4b_codeable_reference_gets_a_concept + reference = Crucible::Tests::ResourceGenerator.generate(FHIR::R4B::CodeableReference) + + assert_instance_of FHIR::R4B::CodeableConcept, reference.concept + assert_not_empty reference.concept.text + assert_nil reference.reference + end + + def test_populated_r4b_codeable_reference_is_preserved + reference = FHIR::R4B::CodeableReference.new + reference.reference = FHIR::R4B::Reference.new(display: 'Existing reference') + + Crucible::Tests::ResourceGenerator.apply_invariants!(reference) + + assert_nil reference.concept + assert_equal 'Existing reference', reference.reference.display + end + + def test_r4b_packaged_product_definition_has_valid_contained_items + resource = Crucible::Tests::ResourceGenerator.generate(FHIR::R4B::PackagedProductDefinition, 5) + packages = [resource.package].compact + contained_items = [] + + until packages.empty? + package = packages.shift + contained_items.concat(package.containedItem || []) + packages.concat(package.package || []) + end + + assert_not_empty contained_items + assert_true contained_items.all? do |contained_item| + contained_item.item && (contained_item.item.concept || contained_item.item.reference) + end + assert_empty FHIR::R4B::Xml.validate(resource.to_xml).map(&:message) + end + def run_generator(resource_type, version, max_depth) klass_namespace = "FHIR" From 84fa7c4a35514bfa4a123bace4cd98b6311e612c Mon Sep 17 00:00:00 2001 From: Kenneth Myhra Date: Thu, 23 Jul 2026 22:14:52 +0200 Subject: [PATCH 11/16] Exclude non-selectable terminology codes from generated resources --- lib/resource_generator.rb | 55 +++++++++++++++++++++++++--- test/unit/resource_generator_test.rb | 38 +++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/lib/resource_generator.rb b/lib/resource_generator.rb index 93106247..5f3cf77c 100644 --- a/lib/resource_generator.rb +++ b/lib/resource_generator.rb @@ -63,7 +63,7 @@ def self.set_fields!(resource, namespace, embedded=0) gen = SecureRandom.uuid elsif type == 'code' if meta['valid_codes'] - gen = meta['valid_codes'].values.first.sample + gen = selectable_valid_codes(meta, namespace).values.flatten.sample elsif meta['binding'] && ['http://tools.ietf.org/html/bcp47','http://hl7.org/fhir/ValueSet/languages'].include?(meta['binding']['uri']) gen = 'en-US' elsif meta['binding'] && ['http://www.rfc-editor.org/bcp/bcp13.txt','http://hl7.org/fhir/ValueSet/content-type'].include?(meta['binding']['uri']) @@ -108,9 +108,10 @@ def self.set_fields!(resource, namespace, embedded=0) gen = generate_child(type, namespace, embedded-1) # apply bindings if type == 'CodeableConcept' && meta['valid_codes'] && meta['binding'] + valid_codes = selectable_valid_codes(meta, namespace) gen.coding.each do |c| - c.system = meta['valid_codes'].keys.sample - c.code = meta['valid_codes'][c.system].sample + c.system = valid_codes.keys.sample + c.code = valid_codes[c.system].sample display = "#{namespace}::Definitions".constantize.get_display(c.system, c.code) if "#{namespace}::Definitions".constantize.respond_to?('get_display') c.display = display ? display : nil end @@ -122,8 +123,9 @@ def self.set_fields!(resource, namespace, embedded=0) elsif type == 'CodeableConcept' && meta['binding'] && meta['binding']['strength'] == 'required' && !meta['valid_codes'] && meta['min'] == 0 gen = nil # Cannot generate valid code for external required binding (e.g. LOINC/SNOMED); field is optional so safe to skip elsif type == 'Coding' && meta['valid_codes'] && meta['binding'] - gen.system = meta['valid_codes'].keys.sample - gen.code = meta['valid_codes'][gen.system].sample + valid_codes = selectable_valid_codes(meta, namespace) + gen.system = valid_codes.keys.sample + gen.code = valid_codes[gen.system].sample display = "#{namespace}::Definitions".constantize.get_display(gen.system, gen.code) if "#{namespace}::Definitions".constantize.respond_to?('get_display') gen.display = display ? display : nil elsif type == 'Reference' @@ -159,6 +161,49 @@ def self.set_fields!(resource, namespace, embedded=0) resource end + def self.selectable_valid_codes(meta, namespace) + valid_codes = meta['valid_codes'] + binding_uri = meta.dig('binding', 'uri') + return valid_codes unless binding_uri + + definitions = "#{namespace}::Definitions".constantize + return valid_codes unless definitions.respond_to?(:expansions) + + @selectable_expansion_codes_cache ||= {} + normalized_uri = binding_uri.sub(/\|[A-Za-z0-9.\-]+\z/, '') + cache_key = [namespace, normalized_uri] + selectable_codes = @selectable_expansion_codes_cache[cache_key] + unless @selectable_expansion_codes_cache.key?(cache_key) + value_set = definitions.expansions.find { |resource| resource['url'] == normalized_uri } + selectable_codes = if value_set + collect_selectable_expansion_codes( + value_set.dig('expansion', 'contains'), + {} + ) + end + @selectable_expansion_codes_cache[cache_key] = selectable_codes + end + return valid_codes unless selectable_codes + + filtered_codes = valid_codes.each_with_object({}) do |(system, codes), filtered| + selectable = codes & selectable_codes.fetch(system, []) + filtered[system] = selectable unless selectable.empty? + end + + filtered_codes.empty? ? valid_codes : filtered_codes + end + + def self.collect_selectable_expansion_codes(entries, codes, inherited_system = nil) + entries.to_a.each do |entry| + system = entry['system'] || inherited_system + if system && entry['code'] && entry['abstract'] != true && entry['inactive'] != true + (codes[system] ||= []) << entry['code'] + end + collect_selectable_expansion_codes(entry['contains'], codes, system) + end + codes + end + def self.ancestor_fhir_classes(klass,namespace) classes = klass.constants classes.concat ancestor_fhir_classes(klass.module_parent, namespace) if klass.module_parent.name != namespace && klass.module_parent != Object diff --git a/test/unit/resource_generator_test.rb b/test/unit/resource_generator_test.rb index bbe4abb4..dbd68f8e 100644 --- a/test/unit/resource_generator_test.rb +++ b/test/unit/resource_generator_test.rb @@ -75,6 +75,40 @@ def test_r4b_packaged_product_definition_has_valid_contained_items assert_empty FHIR::R4B::Xml.validate(resource.to_xml).map(&:message) end + def test_r4b_questionnaire_selectable_codes_exclude_abstract_question + metadata = FHIR::R4B::Questionnaire::Item::METADATA['type'] + generated_codes = Crucible::Tests::ResourceGenerator.selectable_valid_codes(metadata, 'FHIR::R4B') + + assert_include metadata['valid_codes']['http://hl7.org/fhir/item-type'], 'question' + assert_not_include generated_codes['http://hl7.org/fhir/item-type'], 'question' + assert_include generated_codes['http://hl7.org/fhir/item-type'], 'string' + end + + def test_selectable_code_cache_preserves_each_fields_metadata_subset + generator = Crucible::Tests::ResourceGenerator + generator.remove_instance_variable(:@selectable_expansion_codes_cache) if generator.instance_variable_defined?(:@selectable_expansion_codes_cache) + metadata = FHIR::R4B::Questionnaire::Item::METADATA['type'] + generator.selectable_valid_codes(metadata, 'FHIR::R4B') + subset_metadata = metadata.deep_dup + subset_metadata['valid_codes'] = { 'http://hl7.org/fhir/item-type' => ['string'] } + + generated_codes = generator.selectable_valid_codes(subset_metadata, 'FHIR::R4B') + + assert_equal({ 'http://hl7.org/fhir/item-type' => ['string'] }, generated_codes) + end + + def test_generated_r4b_questionnaire_items_use_selectable_types + resource = Crucible::Tests::ResourceGenerator.generate(FHIR::R4B::Questionnaire, 5) + items = questionnaire_items(resource.item) + selectable_types = Crucible::Tests::ResourceGenerator.selectable_valid_codes( + FHIR::R4B::Questionnaire::Item::METADATA['type'], + 'FHIR::R4B' + ).values.flatten + + assert_not_empty items + assert_true items.all? { |item| selectable_types.include?(item.type) } + end + def run_generator(resource_type, version, max_depth) klass_namespace = "FHIR" @@ -106,6 +140,10 @@ def check_valid_namespaces(resource, namespace) return resource.instance_values.values.all? { |v| check_valid_namespaces(v, namespace) } end + def questionnaire_items(items) + items.to_a.flat_map { |item| [item] + questionnaire_items(item.item) } + end + def test_valid_oid_generator 500.times do random_oid = Crucible::Tests::ResourceGenerator.random_oid From 826433bde45f58578291cd30cede4176386ee41f Mon Sep 17 00:00:00 2001 From: Kenneth Myhra Date: Thu, 23 Jul 2026 22:15:54 +0200 Subject: [PATCH 12/16] Enable R4-capable suites for R4B --- lib/tests/suites/fhir_path_patch_test.rb | 21 +++++++++++-------- lib/tests/suites/history_test.rb | 2 +- ...consent_search_by_patient_reference_329.rb | 2 +- .../incendi_elements_search_parameter.rb | 2 +- .../incendi_unknown_search_parameter_1160.rb | 2 +- lib/tests/suites/read_test.rb | 2 +- lib/tests/suites/resource_test.rb | 2 +- lib/tests/suites/search_test.rb | 2 +- lib/tests/suites/search_test_robust.rb | 2 +- lib/tests/suites/sprinkler_search_test.rb | 2 +- lib/tests/suites/transaction_test.rb | 2 +- test/unit/r4b_routing_test.rb | 6 +++--- test/unit/supported_versions_test.rb | 7 ++++--- 13 files changed, 29 insertions(+), 25 deletions(-) diff --git a/lib/tests/suites/fhir_path_patch_test.rb b/lib/tests/suites/fhir_path_patch_test.rb index 9fa956e0..cf5ba165 100644 --- a/lib/tests/suites/fhir_path_patch_test.rb +++ b/lib/tests/suites/fhir_path_patch_test.rb @@ -14,7 +14,7 @@ def initialize(client1, client2 = nil) super(client1, client2) @tags.append('fhirpath') @category = { id: 'fhirpath', title: 'FHIRPath' } - @supported_versions = [:stu3, :r4] + @supported_versions = [:stu3, :r4, :r4b] end def setup @@ -29,7 +29,7 @@ def setup end def teardown - @client.destroy(FHIR::MedicationRequest, @medication_order_id) unless @medication_order_id.nil? + @client.destroy(get_resource(:MedicationRequest), @medication_order_id) unless @medication_order_id.nil? end ['JSON', 'XML'].each do |fmt| @@ -47,9 +47,10 @@ def teardown validates resource: 'MedicationRequest', methods: ['read'] } - reply = @client.read(FHIR::MedicationRequest, @medication_order_id, resource_format(fmt)) + medication_request = get_resource(:MedicationRequest) + reply = @client.read(medication_request, @medication_order_id, resource_format(fmt)) assert_response_ok(reply) - assert_resource_type(reply, FHIR::MedicationRequest) + assert_resource_type(reply, medication_request) assert_resource_content_type(reply, fmt.downcase) warning { assert(!reply.resource.meta.nil?, 'Last Updated and VersionId not present.') @@ -73,14 +74,15 @@ def teardown format = resource_format(fmt) patchset = patchset_resource("replace", "MedicationRequest.status", nil, "completed") - reply = @client.fhir_patch(FHIR::MedicationRequest, @medication_order_id, patchset, {}, format) + medication_request = get_resource(:MedicationRequest) + reply = @client.fhir_patch(medication_request, @medication_order_id, patchset, {}, format) assert_response_ok(reply) warning { - assert_resource_type(reply, FHIR::MedicationRequest) + assert_resource_type(reply, medication_request) assert_resource_content_type(reply, fmt.downcase) } - reply = @client.read(FHIR::MedicationRequest, @medication_order_id, format) + reply = @client.read(medication_request, @medication_order_id, format) assert_response_ok(reply) assert_equal(reply.resource.status, 'completed', 'Status not updated from patch.') warning { @@ -109,10 +111,11 @@ def teardown # According to the FHIR spec, the If-Match eTag for version id should be weak. additional_headers = { 'If-Match' => "\"#{@previous_version_id}\"" } - reply = @client.fhir_patch(FHIR::MedicationRequest, @medication_order_id, patchset, {}, resource_format(fmt), additional_headers) + medication_request = get_resource(:MedicationRequest) + reply = @client.fhir_patch(medication_request, @medication_order_id, patchset, {}, resource_format(fmt), additional_headers) assert_response_conflict(reply) - reply = @client.read(FHIR::MedicationRequest, @medication_order_id, resource_format(fmt)) + reply = @client.read(medication_request, @medication_order_id, resource_format(fmt)) assert_response_ok(reply) assert_equal(reply.resource.status, 'completed', 'Resource should not have been patched because version id was stale.') diff --git a/lib/tests/suites/history_test.rb b/lib/tests/suites/history_test.rb index 3fa54cb3..b94c3cd1 100644 --- a/lib/tests/suites/history_test.rb +++ b/lib/tests/suites/history_test.rb @@ -12,7 +12,7 @@ def description def initialize(client1, client2=nil) super(client1, client2) - @supported_versions = [:dstu2, :stu3, :r4] + @supported_versions = [:dstu2, :stu3, :r4, :r4b] @category = {id: 'core_functionality', title: 'Core Functionality'} end diff --git a/lib/tests/suites/incendi_consent_search_by_patient_reference_329.rb b/lib/tests/suites/incendi_consent_search_by_patient_reference_329.rb index 29f91caf..e741c523 100644 --- a/lib/tests/suites/incendi_consent_search_by_patient_reference_329.rb +++ b/lib/tests/suites/incendi_consent_search_by_patient_reference_329.rb @@ -14,7 +14,7 @@ def initialize(client1, client2 = nil) super(client1, client2) @tags.append('indendilabs') @category = { id: 'indendilabs', title: 'Indendilabs' } - @supported_versions = [:stu3, :r4] + @supported_versions = [:stu3, :r4, :r4b] end def setup diff --git a/lib/tests/suites/incendi_elements_search_parameter.rb b/lib/tests/suites/incendi_elements_search_parameter.rb index ec7cb428..cf412da5 100644 --- a/lib/tests/suites/incendi_elements_search_parameter.rb +++ b/lib/tests/suites/incendi_elements_search_parameter.rb @@ -14,7 +14,7 @@ def initialize(client1, client2 = nil) super(client1, client2) @tags.append('incendilabs') @category = { id: 'incendilabs', title: 'Incendilabs' } - @supported_versions = [:stu3, :r4] + @supported_versions = [:stu3, :r4, :r4b] end def setup diff --git a/lib/tests/suites/incendi_unknown_search_parameter_1160.rb b/lib/tests/suites/incendi_unknown_search_parameter_1160.rb index 4612055e..c8398f54 100644 --- a/lib/tests/suites/incendi_unknown_search_parameter_1160.rb +++ b/lib/tests/suites/incendi_unknown_search_parameter_1160.rb @@ -14,7 +14,7 @@ def initialize(client1, client2 = nil) super(client1, client2) @tags.append('incendilabs') @category = { id: 'incendilabs', title: 'Incendilabs' } - @supported_versions = [:stu3, :r4] + @supported_versions = [:stu3, :r4, :r4b] end def setup diff --git a/lib/tests/suites/read_test.rb b/lib/tests/suites/read_test.rb index ff812ca6..a44c6900 100644 --- a/lib/tests/suites/read_test.rb +++ b/lib/tests/suites/read_test.rb @@ -12,7 +12,7 @@ def description def initialize(client1, client2=nil) super(client1, client2) - @supported_versions = [:dstu2, :stu3, :r4] + @supported_versions = [:dstu2, :stu3, :r4, :r4b] @category = {id: 'core_functionality', title: 'Core Functionality'} end diff --git a/lib/tests/suites/resource_test.rb b/lib/tests/suites/resource_test.rb index 4cb84c49..ef33ac98 100644 --- a/lib/tests/suites/resource_test.rb +++ b/lib/tests/suites/resource_test.rb @@ -50,7 +50,7 @@ def category def initialize(client1, client2=nil) super(client1, client2) - @supported_versions = [:dstu2, :stu3, :r4] + @supported_versions = [:dstu2, :stu3, :r4, :r4b] end # this allows results to have unique ids for resource based tests diff --git a/lib/tests/suites/search_test.rb b/lib/tests/suites/search_test.rb index 0a6daa79..007397fb 100644 --- a/lib/tests/suites/search_test.rb +++ b/lib/tests/suites/search_test.rb @@ -38,7 +38,7 @@ def category def initialize(client1, client2=nil) super(client1, client2) - @supported_versions = [:dstu2, :stu3, :r4] + @supported_versions = [:dstu2, :stu3, :r4, :r4b] end # this allows results to have unique ids for resource based tests diff --git a/lib/tests/suites/search_test_robust.rb b/lib/tests/suites/search_test_robust.rb index d3947318..9ff5c862 100644 --- a/lib/tests/suites/search_test_robust.rb +++ b/lib/tests/suites/search_test_robust.rb @@ -13,7 +13,7 @@ def description def initialize(client1, client2=nil) super(client1, client2) @category = {id: 'core_functionality', title: 'Core Functionality'} - @supported_versions = [:stu3, :r4] + @supported_versions = [:stu3, :r4, :r4b] end def setup diff --git a/lib/tests/suites/sprinkler_search_test.rb b/lib/tests/suites/sprinkler_search_test.rb index 23f28f4e..28a8dce4 100644 --- a/lib/tests/suites/sprinkler_search_test.rb +++ b/lib/tests/suites/sprinkler_search_test.rb @@ -14,7 +14,7 @@ def description def initialize(client1, client2=nil) super(client1, client2) - @supported_versions = [:dstu2, :stu3, :r4] + @supported_versions = [:dstu2, :stu3, :r4, :r4b] @category = {id: 'core_functionality', title: 'Core Functionality'} end diff --git a/lib/tests/suites/transaction_test.rb b/lib/tests/suites/transaction_test.rb index 3e427d32..61c98886 100644 --- a/lib/tests/suites/transaction_test.rb +++ b/lib/tests/suites/transaction_test.rb @@ -12,7 +12,7 @@ def description def initialize(client1, client2=nil) super(client1, client2) - @supported_versions = [:dstu2, :stu3, :r4] + @supported_versions = [:dstu2, :stu3, :r4, :r4b] @category = {id: 'core_functionality', title: 'Core Functionality'} end diff --git a/test/unit/r4b_routing_test.rb b/test/unit/r4b_routing_test.rb index 4b42318b..c288b409 100644 --- a/test/unit/r4b_routing_test.rb +++ b/test/unit/r4b_routing_test.rb @@ -98,7 +98,7 @@ def test_resource_fixture_helper_uses_r4b_namespace assert_same FHIR::R4B, resources.instance_variable_get(:@namespace) end - def test_resource_suite_metadata_does_not_advertise_unaudited_r4b_support + def test_resource_suite_metadata_advertises_r4b_support resource_test = Crucible::Tests::ResourceTest.new(nil) resource_suite_metadata = Crucible::Tests::SuiteEngine.list_all.values.select do |metadata| metadata.key?('resource_class') @@ -107,7 +107,7 @@ def test_resource_suite_metadata_does_not_advertise_unaudited_r4b_support metadata['supported_versions'].include?(:r4b) end - assert_not_include resource_test.supported_versions, :r4b - assert_false advertises_r4b + assert_include resource_test.supported_versions, :r4b + assert_true advertises_r4b end end diff --git a/test/unit/supported_versions_test.rb b/test/unit/supported_versions_test.rb index 9580d708..7288def1 100644 --- a/test/unit/supported_versions_test.rb +++ b/test/unit/supported_versions_test.rb @@ -12,16 +12,17 @@ def test_every_executable_suite_declares_supported_versions end def test_resource_suites_preserve_their_existing_version_support - expected = [:dstu2, :stu3, :r4] + expected = [:dstu2, :stu3, :r4, :r4b] assert_equal expected, Crucible::Tests::ResourceTest.new(nil).supported_versions assert_equal expected, Crucible::Tests::SearchTest.new(nil).supported_versions end - def test_only_audited_suites_advertise_r4b + def test_every_r4_suite_advertises_r4b suites = Crucible::Tests::SuiteEngine.new.tests + r4_suites = suites.select { |suite| suite.supported_versions.include?(:r4) } r4b_suites = suites.select { |suite| suite.supported_versions.include?(:r4b) } - assert_equal [Crucible::Tests::FormatTest], r4b_suites.map(&:class) + assert_equal r4_suites.map(&:class).sort_by(&:name), r4b_suites.map(&:class).sort_by(&:name) end end From 53bf2e1ef6fef55dc175ede9ff34cd62e8110ce5 Mon Sep 17 00:00:00 2001 From: Kenneth Myhra Date: Thu, 23 Jul 2026 22:16:42 +0200 Subject: [PATCH 13/16] Document R4 and R4B endpoint verification --- R4B.md | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 203 insertions(+), 27 deletions(-) diff --git a/R4B.md b/R4B.md index 1f913fde..c8464dd1 100644 --- a/R4B.md +++ b/R4B.md @@ -6,7 +6,11 @@ - Never allow R4B to resolve through implicit R4 fallback. - Shared R4/R4B behavior is allowed only through explicit compatibility annotations. - Keep R4 models at the existing top-level `FHIR::*` namespace for backward compatibility and expose R4B models through the required `FHIR::R4B::*` namespace. -- An omitted version may continue to default to R4, but an explicitly supplied unknown version must fail fast. +- Require an explicit version at every client, task, structure, fixture, and + resource-generator boundary. Omitted and unknown versions must fail fast. +- `fhir_client` may use the explicit sentinel `fhir_version: :auto` only for + CapabilityStatement discovery. Versioned resource operations must remain + unavailable until discovery selects a concrete version. - Keep version annotations explicit, even for resources that are normative or unchanged across R4 and R4B. - Add R4B model support to `fhir_models`; do not introduce a separate R4B model gem by default. - Keep the registry of versions understood by the harness separate from the versions supported by each test suite. @@ -28,17 +32,32 @@ R4B feature work is expected in `fhir_models`, `fhir_client`, and this repositor `cf7f5d5a`, `03059207`, `90cd55fd`, `67738dfe`, and `444f74f7`. - `fhir_client` now has explicit R4B routing and CapabilityStatement version detection in commit `7bde8ed2`. +- Strict client versioning is a separate breaking change in commit `75d3c33`: + `FHIR::Client.new` requires `fhir_version:`, direct R4 remains + `fhir_version: :r4`, and automatic discovery must be requested with + `fhir_version: :auto`. - `plan-executor` now has a central version registry, version-aware resource routing, a generated R4B structure index, corrected version-specific fixture lookup, and explicit suite compatibility annotations. These changes are split across commits `17537c9a`, `2e7a759`, `d70c01c`, `12e10e8`, and `a2b1482`. -- `FormatTest` is the first executable suite audited for R4B compatibility. - All remaining suites must still be audited individually before `:r4b` is - added to their annotations. +- Strict harness versioning and namespace-explicit generator helpers are in + commit `16347df`. +- All 12 executable Ruby suites that already support R4 now explicitly support + R4B in commit `826433b`. STU3-only, DSTU2-only, TestScript, and explicitly + unsupported suites retain their existing annotations. +- The R4B PackagedProductDefinition fixture defect is resolved. Empty generated + `CodeableReference` values now receive a same-namespace text concept, so + recursive `containedItem.item` elements remain present when serialized. The + fix is commit `49e713f`. +- The nondeterministic R4B Questionnaire defect is resolved. Generated codes + are selected from concrete expansion entries while abstract and inactive + entries remain available in the checked-in terminology definitions. The fix + is commit `84fa7c4`. - Remaining integration work consists of publishing or pinning compatible `fhir_models` and `fhir_client` revisions, updating dependency resolution, - auditing candidate suites, and running endpoint smoke tests and the final - cross-version regression matrix. + and running the final cross-version regression matrix. Immutable dependency + revisions must be selected after the feature branches are rebased into + `origin/master`, because those merges change the commit SHAs. ## Baseline And Dependency Actions @@ -155,11 +174,12 @@ R4B feature work is expected in `fhir_models`, `fhir_client`, and this repositor - Keep `supported_versions` as the explicit suite compatibility annotation. - `BaseTest#supported_versions` defaults to an empty list. Do not add `:r4b` or any other implicit compatibility to that default. -- Audit every suite and add `:r4b` only after its behavior, resources, fixtures, and assertions have been checked against R4B. +- Add `:r4b` explicitly to each compatible suite; do not infer R4B support from + R4 support at runtime. - All executable suites now have explicit annotations. Seven suites that previously relied on the base default explicitly preserve their existing - compatibility; `FormatTest` gained R4B only after its focused and endpoint - audits. No suite gains R4B support implicitly. + compatibility. All 12 suites that support R4 now also declare R4B explicitly. + No suite gains R4B support implicitly. - Update resource-based suite enumeration so it intersects known versions, the suite's declared `supported_versions`, and resources available in that version. It must not overwrite a suite's declared compatibility. - Ensure suite listing and suite execution use the same compatibility decision. - Keep TestScripts STU3-only unless separate R4B TestScripts and an R4B TestScript parser are deliberately added. @@ -177,6 +197,11 @@ R4B feature work is expected in `fhir_models`, `fhir_client`, and this repositor ### 2. R4B Routing In `fhir_client` - Add `use_r4b` and route `:r4b` resource lookup, parsing, request replay, response validation, transactions, operations, and capability statements through `FHIR::R4B`. +- Require `fhir_version:` when constructing a client. Do not retain R4 as a + constructor default. +- Permit `fhir_version: :auto` only as an explicit discovery mode. Metadata may + establish a concrete version, but resource operations must reject `:auto` + until that has happened. - Make JSON and XML reply parsing select R4B explicitly instead of allowing the existing non-DSTU2/STU3 fallback to use R4. - Map CapabilityStatement `fhirVersion` values explicitly: `4.0.x` to `:r4` and `4.3.x` to `:r4b`. - Do not classify every version beginning with `4` as R4. Unknown FHIR 4.x releases must be reported as unsupported rather than silently parsed as R4. @@ -184,7 +209,8 @@ R4B feature work is expected in `fhir_models`, `fhir_client`, and this repositor ### 3. R4B Routing In `plan-executor` -- Add `r4b` parsing to the rake version resolver and test that omitted versions default to R4 while unknown supplied values fail fast. +- Add `r4b` parsing to the rake version resolver and test that omitted and + unknown versions fail fast. - Replace scattered version conditionals with a central namespace resolver where practical. - Add explicit R4B namespace and resource resolution in `BaseTest`, `BaseSuite`, OperationOutcome parsing, capability statement handling, fixture validation, resource category lookup, and resource generation helpers. - Initialize R4B base resources with the active client without affecting R4, STU3, or DSTU2 resources. @@ -230,7 +256,8 @@ R4B feature work is expected in `fhir_models`, `fhir_client`, and this repositor - `bundle exec rake crucible:list_all[r4b]` lists only suites explicitly annotated for R4B. - Resource-based suites instantiate R4B classes, not R4 classes, and only enumerate resources present in `FHIR::R4B::RESOURCES`. - Listing and executing suites apply identical version eligibility rules. -- An omitted CLI version still selects R4; an unknown supplied version exits with a clear error. +- An omitted or unknown CLI version exits with a clear error. R4 must be + selected explicitly with `r4`. - `FHIR_structure_r4b.json` passes resource-list consistency and duplicate-name checks. - Version-specific fixture override selection has focused unit coverage. - `FormatTest` passes all 22 cases against a Spark endpoint whose @@ -255,8 +282,8 @@ R4B feature work is expected in `fhir_models`, `fhir_client`, and this repositor - Only suites whose existing `supported_versions` declaration contained `:r4` were temporarily given `:r4b`. STU3-only, DSTU2-only, TestScript, and explicitly unsupported suites were not enabled. These temporary annotations - were made in an isolated copy and are not evidence that every suite should be - permanently annotated for R4B. + were made in an isolated copy. The later 2026-07-23 verification below + records the permanent annotations after strict version routing was added. - The normal suite registry exposed 12 eligible suites. Eligibility was checked with: @@ -365,13 +392,12 @@ they are not 16 independent compatibility defects. PackagedProductDefinition recursion, and Questionnaire item-type selection. 2. Make the resource generator namespace-aware where it currently dispatches only on top-level R4 classes. Review the rest of `apply_invariants!` for the - same pattern before adding broad R4B suite annotations. + same pattern. 3. Fix and rerun the three targeted commands above, including `TransactionAndBatchTest` through `crucible:execute`. 4. Repeat the complete 12-suite R4B endpoint run and retain per-suite output and shell exit status. -5. Permanently add `:r4b` only to suites that pass their focused audit, then run - the existing R4 unit and endpoint regression suites to detect shared +5. Run the existing R4 unit and endpoint regression suites to detect shared generator regressions. ### Existing Endpoint Regression Baselines @@ -388,17 +414,165 @@ baseline for later cross-version regression runs. Future runs should compare both the totals and the individual skipped tests. The totals alone do not establish whether a changed skip is expected. +## Strict Versioning Docker Verification (2026-07-23) + +- The Docker image was built with the repository `Dockerfile` and a disposable + build context containing the current local `fhir_client`, `fhir_models`, + `fhir_stu3_models`, and `fhir_dstu2_models` working trees as `path:` + dependencies. The resulting image was + `incendi/plan_executor:strict-r4b`, image ID + `sha256:b5ae435552fd1305d072c021d1b140db1461daa2b23d2cd1183855ec96bfa975`. +- The plan-executor unit suite passed `1218` tests and `3680` assertions with no + failures or errors inside that image. +- Focused `fhir_client` coverage for required versions, `:auto`, R4B routing, + and external references passed `79` tests and `186` assertions with no + failures or errors. +- The complete modified `fhir_client` suite reported `114` tests, `269` + assertions, and the same five errors reproduced by the committed baseline in + the identical container. Four are caused by invalid JSON escapes in the + existing `fhir_api_validation.json`; one is existing shared model-client + state in `test_class_partial_update`. The strict-versioning change introduced + no additional full-suite failures. +- The CI-style Compose run used + `sparkfhir/spark:r4b-latest` image ID + `sha256:d5139dcba0a3e17aac71b36d31271326111248ad4af1bc16b095d423f2b7d2d8` + and `sparkfhir/mongo:r4b-latest` image ID + `sha256:9c8e741da8cbce3b5e10e845717c368f41a2e27311912541ed2192110d4d7741`. + The initial + `./execute_all.sh http://spark:8080/fhir r4b html|json|stdout` run included + only `FormatTest` and passed all `22` cases. The expanded run is recorded + below. + +## R4B Enabled-Suite Verification (2026-07-23) + +- Every Ruby suite that declares R4 support now also explicitly declares R4B + support. `crucible:list_suites[r4b]` lists 12 suites: + `ReadTest`, `ResourceTest`, `FhirPathPatchTest`, `FormatTest`, + `TransactionAndBatchTest`, `SearchTest`, `HistoryTest`, `RobustSearchTest`, + `SprinklerSearchTest`, `ConsentSearchByPatientReferenceTest`, + `ElementsSearchParameterTest`, and `UnknownSearchParameterTest`. +- `FhirPathPatchTest` now obtains `MedicationRequest` through its selected + version namespace instead of using the top-level R4 model class. An audit of + the other 11 suites found no unversioned resource-model constants. +- A unit invariant requires the set of R4 suites and the set of R4B suites to + remain identical. The Docker unit run passed `1218` tests and `3680` + assertions with no failures or errors. +- The CI-style endpoint command was: + + ```sh + docker compose run --rm --no-deps plan_executor \ + ./execute_all.sh http://spark:8080/fhir r4b 'html|json|stdout' + ``` + +- The disposable plan-executor image was + `incendi/plan_executor:all-r4b`, image ID + `sha256:7891bc0c6347a99b54d5b5382b9081d7c8b422aee6bd3d85ca93e73c4cdd3221`. + The Spark and Mongo image IDs were + `sha256:d5139dcba0a3e17aac71b36d31271326111248ad4af1bc16b095d423f2b7d2d8` + and + `sha256:9c8e741da8cbce3b5e10e845717c368f41a2e27311912541ed2192110d4d7741`. +- The endpoint run completed 3,585 tests in 234 seconds: + + | Pass | Fail | Error | TODO skip | + | ---: | ---: | ---: | ---: | + | 3149 | 8 | 0 | 428 | + +- All eight failures in this run were in + `ResourceTest_PackagedProductDefinition`. Three create or update requests + were rejected because generated nested package entries omitted required + `containedItem.item`; five conditional and history assertions then failed + because those resources were not created. +- All 428 skips remain existing `TODO` skips. FHIR TestScript artifacts remain + explicitly STU3-only and were not part of this R4B run. + +### PackagedProductDefinition Generator Fix + +- The root cause was a recursion-boundary `CodeableReference` object with + neither `concept` nor `reference`. Although the required object existed in + memory, it serialized as an empty object and the effective + `containedItem.item` element was omitted. +- `ResourceGenerator` now gives an otherwise empty generated + `CodeableReference` a text-only `CodeableConcept` from the selected FHIR + namespace. Existing concept or reference values are preserved. This is a + datatype invariant rather than a PackagedProductDefinition-specific + traversal. +- Focused unit coverage checks empty and populated R4B CodeableReference + behavior, every recursively generated contained item, and R4B XML schema + validation. The Docker unit suite passed `1221` tests and `3688` assertions + with no failures or errors. +- A fresh focused endpoint run of + `ResourceTest_PackagedProductDefinition` completed with `15` passes, no + failures or errors, and the existing `3` TODO skips. +- A subsequent fresh 12-suite run confirmed the same PackagedProductDefinition + result. The aggregate result was `3151` passes, `6` failures, no errors, and + `428` TODO skips. All six failures were the separately documented + nondeterministic Questionnaire `type: question` defect; none belonged to + PackagedProductDefinition. +- Verification used `incendi/plan_executor:packaged-product-fix`, image ID + `sha256:b8a9ee41b11496a99e38a6dc93ba061d720075477c31d9ea5deafcf6f5900c62`, + with the same R4B Spark and Mongo image IDs recorded above. + +### Questionnaire Selectable-Code Fix + +- Generated model metadata continues to contain the complete required + `QuestionnaireItemType` code set, including the abstract `question` grouping + code. The terminology artifacts and generated models were not rewritten. +- `ResourceGenerator` now derives a cached selectable-code set from the + namespace's original ValueSet expansion. Entries marked `abstract` or + `inactive` are excluded from generated instances, while concrete descendants + of abstract grouping entries remain selectable. +- The same filtering is used for primitive `code`, `Coding`, and + `CodeableConcept` generation. If a binding has no matching expansion or + filtering would remove every generated code, the existing generated metadata + remains the fallback. +- Focused tests prove that the complete R4B Questionnaire metadata still + includes `question`, the selectable set excludes it, and every recursively + generated Questionnaire item uses a selectable type. The Docker unit suite + passed `1224` tests and `3694` assertions with no failures or errors. +- Three initial consecutive `ResourceTest_Questionnaire` endpoint runs each + completed with `15` passes and the existing `3` TODO skips. After correcting + the cache scope to cache expansion data rather than field-specific + intersections, the final image produced the same focused result and no + generated Questionnaire payload contained `type: question`. +- A final fresh 12-suite R4B run completed in 236 seconds with `3157` passes, + no failures or errors, and `428` existing TODO skips. The command exited + successfully, and PackagedProductDefinition remained clean in the same run. +- Verification used `incendi/plan_executor:questionnaire-fix`, image ID + `sha256:f6dda44f11bcf8eafac55695d4e9f066f377473b3310963e2b3d94fa83ca7680`, + with the same R4B Spark and Mongo image IDs recorded above. + +### R4 Regression Verification + +- The final Questionnaire implementation was run against the fresh + `sparkfhir/spark:r4-latest` and `sparkfhir/mongo:r4-latest` images, with image + IDs + `sha256:411d6ea0d92c8001359eec3d749c643a28b207f3ac6f0f3362f1d39081348bf7` + and + `sha256:cf64b34e58f6f88f5350cef5066456f69585e4ec4716bcdb56579e3153950e4c`. + The plan-executor image was the same final image recorded above. +- The R4 Spark image contains a local Kestrel HTTPS endpoint configuration but + no server certificate. Its first startup therefore entered a restart loop, + and that infrastructure-only run was discarded. The clean retry used the + disposable environment override + `Kestrel__Endpoints__Https__Url=http://+:8080`; neither the image nor + repository configuration was changed. +- The fresh full R4 run completed in 145 seconds with `3267` passes, no failures + or errors, and `443` TODO skips. The command exited successfully and exactly + matched the recorded R4 aggregate baseline. +- `ResourceTest_Questionnaire` completed with `15` passes, no failures or + errors, and the existing `3` TODO skips. No generated Questionnaire payload + contained `type: question`. + ### Deferred STU3 TestScript Regression - Run the 71 FHIR TestScript artifacts against a STU3 endpoint as a separate regression exercise. They remain explicitly STU3-only and are not part of either the R4 or R4B suite runs. -- Before relying on that run, correct or work around the dedicated - `crucible:execute_all_testscripts` and `crucible:testreport` tasks. They do not - currently accept a FHIR version, do not call `use_fhir_version`, and bypass - the normal `supported_versions` filter. Because a new `FHIR::Client` defaults - to R4, invoking those tasks as written does not guarantee a STU3 client even - though the TestScript artifacts are parsed and annotated as STU3. +- The dedicated `crucible:execute_all_testscripts` and + `crucible:testreport` tasks now require a FHIR version and construct the + client with that version. They still bypass the normal suite + `supported_versions` filter, so the deferred regression must invoke them with + `stu3` explicitly and verify the endpoint version. - The eventual regression command must explicitly select `:stu3`, retain per-TestScript output and shell exit status, and use a CapabilityStatement to confirm that the target endpoint reports STU3 before execution. @@ -410,8 +584,10 @@ The totals alone do not establish whether a changed skip is expected. 3. Complete: generate and validate the R4B model set. 4. Complete: add R4B routing, parsing, capability handling, and detection to `fhir_client`. 5. Complete: add the central version registry and fail-fast version resolution to `plan-executor`. -6. In progress: `FormatTest` is audited and enabled; audit additional suites - and add explicit R4B compatibility annotations only where verified. -7. In progress: R4B structures, documentation, and the first endpoint smoke - run are complete; additional R4B fixtures, dependency updates, and the full - cross-version endpoint regression matrix remain. +6. Complete: all 12 R4-capable Ruby suites explicitly declare R4B support and + have been run against the R4B endpoint. +7. In progress: update dependencies and complete the STU3 and DSTU2 endpoint + regression matrix. Fresh R4 and R4B endpoint runs are complete. +8. Complete: remove implicit R4 defaults from `fhir_client` and + `plan-executor`, make generator namespaces explicit, verify the breaking + change in Docker, and commit it atomically. From b27a1d911f8bbed2a5ae4026c7ce5fa1e4d3f25a Mon Sep 17 00:00:00 2001 From: Kenneth Myhra Date: Thu, 23 Jul 2026 22:32:51 +0200 Subject: [PATCH 14/16] Use merged R4B model and client dependencies --- Gemfile | 4 ++-- Gemfile.lock | 23 +++++++++++++++-------- R4B.md | 33 +++++++++++++++++++++++++-------- 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/Gemfile b/Gemfile index b19e1a67..2b768960 100644 --- a/Gemfile +++ b/Gemfile @@ -2,8 +2,8 @@ source "https://rubygems.org" gem 'fhir_dstu2_models', git: 'https://github.com/incendilabs/fhir_dstu2_models.git' gem 'fhir_stu3_models', git: 'https://github.com/incendilabs/fhir_stu3_models.git' -gem 'fhir_client', git: 'https://github.com/incendilabs/fhir_client.git' -gem 'fhir_models', '~> 4.1' +gem 'fhir_client', git: 'https://github.com/incendilabs/fhir_client.git', branch: 'master' +gem 'fhir_models', git: 'https://github.com/incendilabs/fhir_models.git', branch: 'master' gem 'activesupport', '>= 6' gem 'pry', '~> 0.15.0' gemspec diff --git a/Gemfile.lock b/Gemfile.lock index cdbe96b9..d25799ad 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,9 @@ GIT remote: https://github.com/incendilabs/fhir_client.git - revision: a2298086b051f18595dcfe5768f63545f2a271bb + revision: 79026641f9b2ac7cf30bc27a3528e505d34c67e8 + branch: master specs: - fhir_client (4.0.4) + fhir_client (5.0.0) activesupport (>= 3) addressable (>= 2.9.0) fhir_dstu2_models (>= 1.0.10) @@ -24,6 +25,17 @@ GIT mime-types (>= 3.0) nokogiri (>= 1.10.4) +GIT + remote: https://github.com/incendilabs/fhir_models.git + revision: a143d2e21d0253b33fdaeb17e2d152ad656c9a3e + branch: master + specs: + fhir_models (4.1.0) + bcp47 (>= 0.3) + date_time_precision (>= 0.8) + mime-types (>= 3.0) + nokogiri (>= 1.10.4) + GIT remote: https://github.com/incendilabs/fhir_stu3_models.git revision: 71db01196b6cafe2310498135849cae356fe6f44 @@ -90,11 +102,6 @@ GEM logger faraday-net_http (3.4.4) net-http (~> 0.5) - fhir_models (4.3.0) - bcp47 (>= 0.3) - date_time_precision (>= 0.8) - mime-types (>= 3.0) - nokogiri (>= 1.11.4) hashdiff (1.2.1) http-accept (1.7.0) http-cookie (1.1.4) @@ -182,7 +189,7 @@ DEPENDENCIES awesome_print fhir_client! fhir_dstu2_models! - fhir_models (~> 4.1) + fhir_models! fhir_stu3_models! plan_executor! pry (~> 0.15.0) diff --git a/R4B.md b/R4B.md index c8464dd1..1cf74249 100644 --- a/R4B.md +++ b/R4B.md @@ -53,11 +53,11 @@ R4B feature work is expected in `fhir_models`, `fhir_client`, and this repositor are selected from concrete expansion entries while abstract and inactive entries remain available in the checked-in terminology definitions. The fix is commit `84fa7c4`. -- Remaining integration work consists of publishing or pinning compatible - `fhir_models` and `fhir_client` revisions, updating dependency resolution, - and running the final cross-version regression matrix. Immutable dependency - revisions must be selected after the feature branches are rebased into - `origin/master`, because those merges change the commit SHAs. +- `plan-executor` now resolves `fhir_models` and `fhir_client` from their merged + `origin/master` branches, with the exact resolved revisions retained in + `Gemfile.lock`. +- Remaining integration work consists of completing the STU3 and DSTU2 + cross-version regression matrix. ## Baseline And Dependency Actions @@ -83,7 +83,8 @@ R4B feature work is expected in `fhir_models`, `fhir_client`, and this repositor for the XML schema archive. - Define the local cross-repository development setup, using temporary `path:` dependencies or equivalent local wiring so changes in `../fhir_models` and `../fhir_client` are exercised by this repository. - Define the release and dependency update order: `fhir_models`, then `fhir_client`, then `plan-executor`. -- Update gem version constraints and `Gemfile.lock` to released versions or immutable commit references before final integration. +- Complete: resolve `fhir_models` and `fhir_client` from their merged master + branches and retain their immutable resolved revisions in `Gemfile.lock`. ## Model Definition Architecture @@ -563,6 +564,22 @@ The totals alone do not establish whether a changed skip is expected. errors, and the existing `3` TODO skips. No generated Questionnaire payload contained `type: question`. +### Merged Dependency Verification + +- `fhir_models` resolves from + `https://github.com/incendilabs/fhir_models.git` master revision + `a143d2e21d0253b33fdaeb17e2d152ad656c9a3e`. +- `fhir_client` resolves from + `https://github.com/incendilabs/fhir_client.git` master revision + `79026641f9b2ac7cf30bc27a3528e505d34c67e8`. +- A repository Docker build using only the merged Git dependencies produced + `incendi/plan_executor:master-r4b-deps`, image ID + `sha256:927c11680443ee857d039ec71eea63cfa8b6c28da5a96d494a1c5b9c9f974345`. + The image loaded `fhir_client` 5.0.0, `fhir_models` 4.1.0, and + `FHIR::R4B::Patient`. +- The Docker unit suite passed `1224` tests and `3694` assertions with no + failures or errors. + ### Deferred STU3 TestScript Regression - Run the 71 FHIR TestScript artifacts against a STU3 endpoint as a separate @@ -586,8 +603,8 @@ The totals alone do not establish whether a changed skip is expected. 5. Complete: add the central version registry and fail-fast version resolution to `plan-executor`. 6. Complete: all 12 R4-capable Ruby suites explicitly declare R4B support and have been run against the R4B endpoint. -7. In progress: update dependencies and complete the STU3 and DSTU2 endpoint - regression matrix. Fresh R4 and R4B endpoint runs are complete. +7. In progress: complete the STU3 and DSTU2 endpoint regression matrix. Merged + dependency resolution and fresh R4 and R4B endpoint runs are complete. 8. Complete: remove implicit R4 defaults from `fhir_client` and `plan-executor`, make generator namespaces explicit, verify the breaking change in Docker, and commit it atomically. From 4593959559c7d026c7c2ee808706c7b291c7eff4 Mon Sep 17 00:00:00 2001 From: Kenneth Myhra Date: Thu, 23 Jul 2026 23:02:00 +0200 Subject: [PATCH 15/16] CI: Build Spark docker images from source --- .github/workflows/ci-stu3.yml | 33 ++++++++++++++++++++++++--------- .github/workflows/ci.yml | 21 ++++++++++++++++++--- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci-stu3.yml b/.github/workflows/ci-stu3.yml index 018594cf..731c0b94 100644 --- a/.github/workflows/ci-stu3.yml +++ b/.github/workflows/ci-stu3.yml @@ -14,18 +14,33 @@ jobs: runs-on: ubuntu-24.04 steps: - - name: Checkout repo + name: Checkout plan-executor uses: actions/checkout@v7 - - name: Build docker image + name: Build plan-executor Docker image run: docker build -t incendi/plan_executor:latest . + - + name: Checkout Spark + uses: actions/checkout@v7 + with: + repository: FirelyTeam/spark + ref: master + path: spark + - + name: Build Spark STU3 Docker image + run: docker build spark --file spark/.docker/linux/Spark.STU3.Dockerfile + --tag sparkfhir/spark:stu3-latest + - + name: Build Mongo STU3 Docker image + run: docker build spark --file spark/.docker/linux/Mongo.STU3.Dockerfile + --tag sparkfhir/mongo:stu3-latest - name: Run tests run: | mkdir -p logs html_summaries json_results docker compose -f docker-compose-stu3.yml up -d spark - docker compose run --rm --no-deps plan_executor ./execute_all.sh 'http://spark:8080/fhir' stu3 'html|json|stdout' - docker compose logs spark > logs/backend.log + docker compose -f docker-compose-stu3.yml run --rm --no-deps plan_executor ./execute_all.sh 'http://spark:8080/fhir' stu3 'html|json|stdout' + docker compose -f docker-compose-stu3.yml logs spark > logs/backend.log - name: Combine test results if: ${{ always() }} @@ -42,30 +57,30 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@v7 with: - name: logs-r4-${{ github.sha }} + name: logs-stu3-${{ github.sha }} path: logs/*.log* - name: Archive test reports if: ${{ always() }} uses: actions/upload-artifact@v7 with: - name: html_summaries-r4-${{ github.sha }} + name: html_summaries-stu3-${{ github.sha }} path: html_summaries/**/*.html - name: Archive JSON results if: ${{ always() }} uses: actions/upload-artifact@v7 with: - name: json_results-r4-${{ github.sha }} + name: json_results-stu3-${{ github.sha }} path: json_results/**/*.json - name: Archive annotations file if: ${{ always() }} uses: actions/upload-artifact@v7 with: - name: annotations-r4-${{ github.sha }} + name: annotations-stu3-${{ github.sha }} path: annotations.json - name: Cleanup if: ${{ always() }} - run: docker compose down \ No newline at end of file + run: docker compose -f docker-compose-stu3.yml down diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a215af51..eac36812 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,11 +14,26 @@ jobs: runs-on: ubuntu-24.04 steps: - - name: Checkout repo + name: Checkout plan-executor uses: actions/checkout@v7 - - name: Build docker image + name: Build plan-executor Docker image run: docker build -t incendi/plan_executor:latest . + - + name: Checkout Spark + uses: actions/checkout@v7 + with: + repository: FirelyTeam/spark + ref: master + path: spark + - + name: Build Spark R4 Docker image + run: docker build spark --file spark/.docker/linux/Spark.R4.Dockerfile + --tag sparkfhir/spark:r4-latest + - + name: Build Mongo R4 Docker image + run: docker build spark --file spark/.docker/linux/Mongo.R4.Dockerfile + --tag sparkfhir/mongo:r4-latest - name: Run tests run: | @@ -68,4 +83,4 @@ jobs: - name: Cleanup if: ${{ always() }} - run: docker compose down \ No newline at end of file + run: docker compose down From 2db807ee83df9233774959e96be4786e71416bfb Mon Sep 17 00:00:00 2001 From: Kenneth Myhra Date: Thu, 23 Jul 2026 23:05:33 +0200 Subject: [PATCH 16/16] CI: Run integration test suite for R4B --- .github/workflows/ci-r4b.yml | 86 ++++++++++++++++++++++++++++++++++++ docker-compose-r4b.yml | 5 +++ 2 files changed, 91 insertions(+) create mode 100644 .github/workflows/ci-r4b.yml create mode 100644 docker-compose-r4b.yml diff --git a/.github/workflows/ci-r4b.yml b/.github/workflows/ci-r4b.yml new file mode 100644 index 00000000..585ea478 --- /dev/null +++ b/.github/workflows/ci-r4b.yml @@ -0,0 +1,86 @@ +name: CI R4B + +on: + workflow_dispatch: + push: + branches: + - 'master' + pull_request: + +jobs: + build: + if: github.repository == 'incendilabs/plan-executor' + + runs-on: ubuntu-24.04 + steps: + - + name: Checkout plan-executor + uses: actions/checkout@v7 + - + name: Build plan-executor Docker image + run: docker build -t incendi/plan_executor:latest . + - + name: Checkout Spark + uses: actions/checkout@v7 + with: + repository: FirelyTeam/spark + ref: master + path: spark + - + name: Build Spark R4B Docker image + run: docker build spark --file spark/.docker/linux/Spark.R4B.Dockerfile + --tag sparkfhir/spark:r4b-latest + - + name: Build Mongo R4B Docker image + run: docker build spark --file spark/.docker/linux/Mongo.R4B.Dockerfile + --tag sparkfhir/mongo:r4b-latest + - + name: Run tests + run: | + mkdir -p logs html_summaries json_results + docker compose -f docker-compose.yml -f docker-compose-r4b.yml up -d spark + docker compose -f docker-compose.yml -f docker-compose-r4b.yml run --rm --no-deps plan_executor ./execute_all.sh 'http://spark:8080/fhir' r4b 'html|json|stdout' + docker compose -f docker-compose.yml -f docker-compose-r4b.yml logs spark > logs/backend.log + - + name: Combine test results + if: ${{ always() }} + run: ./combine-test-results.sh json_results annotations.json + - + name: Attach test results + if: github.event_name != 'pull_request' + uses: yuzutech/annotations-action@v0.6.0 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + input: annotations.json + - + name: Archive logs + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: logs-r4b-${{ github.sha }} + path: logs/*.log* + - + name: Archive test reports + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: html_summaries-r4b-${{ github.sha }} + path: html_summaries/**/*.html + - + name: Archive JSON results + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: json_results-r4b-${{ github.sha }} + path: json_results/**/*.json + - + name: Archive annotations file + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: annotations-r4b-${{ github.sha }} + path: annotations.json + - + name: Cleanup + if: ${{ always() }} + run: docker compose -f docker-compose.yml -f docker-compose-r4b.yml down diff --git a/docker-compose-r4b.yml b/docker-compose-r4b.yml new file mode 100644 index 00000000..5e4510bf --- /dev/null +++ b/docker-compose-r4b.yml @@ -0,0 +1,5 @@ +services: + spark: + image: sparkfhir/spark:r4b-latest + mongodb: + image: sparkfhir/mongo:r4b-latest