inferno_core 1.4.1 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/inferno/apps/cli/execute_script.rb +27 -5
- data/lib/inferno/apps/cli/requirements_coverage_checker.rb +1 -1
- data/lib/inferno/apps/cli/session/session_compare.rb +27 -3
- data/lib/inferno/apps/cli/session/session_status.rb +20 -4
- data/lib/inferno/apps/web/controllers/test_runs/create.rb +8 -1
- data/lib/inferno/apps/web/controllers/test_runs/destroy.rb +10 -1
- data/lib/inferno/config/boot/db.rb +45 -0
- data/lib/inferno/config/boot/executor.rb +4 -0
- data/lib/inferno/config/boot/web.rb +4 -0
- data/lib/inferno/dsl/fhir_resource_navigation.rb +12 -2
- data/lib/inferno/dsl/fhir_resource_validation.rb +96 -0
- data/lib/inferno/dsl/input_output_handling.rb +7 -5
- data/lib/inferno/dsl/must_support_assessment.rb +19 -8
- data/lib/inferno/dsl/must_support_metadata_extractor.rb +44 -1
- data/lib/inferno/dsl/profile_metadata.rb +113 -0
- data/lib/inferno/dsl/resume_test_route.rb +14 -2
- data/lib/inferno/dsl/suite_endpoint.rb +176 -7
- data/lib/inferno/dsl.rb +1 -0
- data/lib/inferno/entities/ig.rb +51 -8
- data/lib/inferno/jobs.rb +5 -1
- data/lib/inferno/public/bundle.js +34 -34
- data/lib/inferno/public/bundle.js.LICENSE.txt +0 -2
- data/lib/inferno/test_runner.rb +61 -28
- data/lib/inferno/utils/db_concurrency_check.rb +166 -0
- data/lib/inferno/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 044cfcbc6d3920cfba55d0d63fbf0fb3a1440f4f42a2e45d763beb78f280306a
|
|
4
|
+
data.tar.gz: 243c006acc71fddd3eb165a2c400a1eab4e515f8c3f4915b27cd60fc8e41e734
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a1c934c0c6bb863a66e34b2830807ee274f443323d7c74c10023bf078329abf2bc70d9125f481b388c3e9ac61a647c81a8f6ee6f7c156078a9dc4fc31adcb671
|
|
7
|
+
data.tar.gz: 6a54d11609e88fadd4c4750966e66a1df705ab1a1c79b8c8c0b99f5d3a19467496c46db740dfb2937610c9c78e8a7bf14db7fe8112c7c8399bb43a16bdcd6937
|
|
@@ -137,8 +137,8 @@ module Inferno
|
|
|
137
137
|
)
|
|
138
138
|
|
|
139
139
|
ExecutionStatus = Struct.new(
|
|
140
|
-
:done, :failed, :timed_out, :current_session, :current_timeout, :last_log_time,
|
|
141
|
-
:cross_session_status, :last_step_signatures
|
|
140
|
+
:done, :failed, :timed_out, :cancel_pending, :current_session, :current_timeout, :last_log_time,
|
|
141
|
+
:poll_start_time, :cross_session_status, :last_step_signatures
|
|
142
142
|
)
|
|
143
143
|
|
|
144
144
|
attr_accessor :yaml_file, :options, :execution_status
|
|
@@ -152,8 +152,10 @@ module Inferno
|
|
|
152
152
|
done: false,
|
|
153
153
|
failed: false,
|
|
154
154
|
timed_out: false,
|
|
155
|
+
cancel_pending: false,
|
|
155
156
|
current_session: sessions.first,
|
|
156
157
|
current_timeout: options[:default_poll_timeout],
|
|
158
|
+
poll_start_time: nil,
|
|
157
159
|
cross_session_status: {},
|
|
158
160
|
last_step_signatures: {}
|
|
159
161
|
)
|
|
@@ -234,13 +236,15 @@ module Inferno
|
|
|
234
236
|
creator = Session::CreateSession.new(suite, session_create_options(session_config))
|
|
235
237
|
session_details = creator.create_session
|
|
236
238
|
key = session_config['name'] || suite
|
|
237
|
-
|
|
238
|
-
ScriptSession.new(
|
|
239
|
+
script_session = ScriptSession.new(
|
|
239
240
|
key: key,
|
|
240
241
|
suite_id: session_details['test_suite_id'],
|
|
241
242
|
session_id: session_details['id'],
|
|
242
243
|
short_id_map: extract_short_ids_from_session_details(session_details)
|
|
243
244
|
)
|
|
245
|
+
warn "Session created: #{session_details['id']}"
|
|
246
|
+
warn " Available at #{session_display_url(script_session)}"
|
|
247
|
+
script_session
|
|
244
248
|
end
|
|
245
249
|
|
|
246
250
|
def session_create_options(session_config)
|
|
@@ -413,6 +417,7 @@ module Inferno
|
|
|
413
417
|
warn ''
|
|
414
418
|
warn "Polling session: #{session.key} (#{session.session_id}) timeout=#{timeout}s"
|
|
415
419
|
deadline = Time.now + timeout
|
|
420
|
+
execution_status.poll_start_time = Time.now
|
|
416
421
|
execution_status.last_log_time = Time.now - LOG_INTERVAL_SECONDS
|
|
417
422
|
|
|
418
423
|
loop do
|
|
@@ -442,6 +447,10 @@ module Inferno
|
|
|
442
447
|
|
|
443
448
|
# Returns a step hash to act on, or nil to keep polling.
|
|
444
449
|
def handle_actionable_status(status, session, timeout)
|
|
450
|
+
# A cancelled run's done status is indistinguishable from the
|
|
451
|
+
# runnable completing normally, so don't match steps against it.
|
|
452
|
+
return handle_cancel_completion(status, session, timeout) if execution_status.cancel_pending
|
|
453
|
+
|
|
445
454
|
matched_step = match_step(status, session.key)
|
|
446
455
|
|
|
447
456
|
if matched_step
|
|
@@ -463,10 +472,16 @@ module Inferno
|
|
|
463
472
|
last_completed = format_last_completed(last_completed_from_status(status), session.key)
|
|
464
473
|
warn "UNHANDLED WAIT - Canceling: session=#{session.key} last_completed=#{last_completed}"
|
|
465
474
|
execution_status.failed = true
|
|
475
|
+
execution_status.cancel_pending = true
|
|
466
476
|
attempt_cancel(session.session_id, status)
|
|
467
477
|
nil
|
|
468
478
|
end
|
|
469
479
|
|
|
480
|
+
def handle_cancel_completion(status, session, timeout)
|
|
481
|
+
warn "Cancellation complete: session=#{session.key} status=#{status['status']}"
|
|
482
|
+
{ command: nil, timeout: timeout, next_poll_session: nil }
|
|
483
|
+
end
|
|
484
|
+
|
|
470
485
|
def handle_unmatched_status(status, session, timeout)
|
|
471
486
|
run_status = status['status']
|
|
472
487
|
last_completed = format_last_completed(last_completed_from_status(status), session.key)
|
|
@@ -485,6 +500,7 @@ module Inferno
|
|
|
485
500
|
execution_status.failed = true
|
|
486
501
|
if run_status == 'waiting'
|
|
487
502
|
warn "Loop detected - Canceling: session=#{session.key} last_completed=#{last_completed}"
|
|
503
|
+
execution_status.cancel_pending = true
|
|
488
504
|
attempt_cancel(session.session_id, status)
|
|
489
505
|
return nil
|
|
490
506
|
else
|
|
@@ -504,10 +520,16 @@ module Inferno
|
|
|
504
520
|
last_completed = last_completed_from_status(status)
|
|
505
521
|
poll_status_last_test =
|
|
506
522
|
last_completed.present? ? " - last test: #{format_last_completed(last_completed, session_key)}" : ''
|
|
507
|
-
|
|
523
|
+
elapsed = (Time.now - execution_status.poll_start_time).round
|
|
524
|
+
warn " [#{session_key}] #{status['status']} (#{test_progress(status, elapsed)})" \
|
|
525
|
+
"#{poll_status_last_test}"
|
|
508
526
|
execution_status.last_log_time = Time.now
|
|
509
527
|
end
|
|
510
528
|
|
|
529
|
+
def test_progress(status, elapsed)
|
|
530
|
+
"completed #{status['completed_test_count']}/#{status['test_count']} tests in #{elapsed}s"
|
|
531
|
+
end
|
|
532
|
+
|
|
511
533
|
def fetch_session_status(session_id)
|
|
512
534
|
Session::SessionStatus.new(session_id, options).status_for_session
|
|
513
535
|
end
|
|
@@ -119,7 +119,7 @@ module Inferno
|
|
|
119
119
|
suite_runnables.select { |runnable| runnable.verifies_requirements.include? requirement_id }
|
|
120
120
|
|
|
121
121
|
runnables_for_requirement.map do |runnable|
|
|
122
|
-
[requirement_id, runnable.short_id, runnable.id]
|
|
122
|
+
[requirement_id, runnable < Inferno::Entities::TestSuite ? 'suite' : runnable.short_id, runnable.id]
|
|
123
123
|
end
|
|
124
124
|
end
|
|
125
125
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require 'csv'
|
|
2
2
|
require 'cgi'
|
|
3
|
+
require 'uri'
|
|
3
4
|
require_relative 'session_details'
|
|
4
5
|
require_relative 'session_results'
|
|
5
6
|
|
|
@@ -82,16 +83,39 @@ module Inferno
|
|
|
82
83
|
end
|
|
83
84
|
|
|
84
85
|
def save_actual_results_to_file
|
|
85
|
-
actual_results_file_name = "#{output_file_prefix}actual_results_#{results_timestamp}.json"
|
|
86
|
-
File.write(File.join(output_directory, actual_results_file_name), session_results
|
|
86
|
+
actual_results_file_name = "#{output_file_prefix}actual_results_#{results_timestamp}#{host_suffix}.json"
|
|
87
|
+
File.write(File.join(output_directory, actual_results_file_name), JSON.pretty_generate(session_results))
|
|
87
88
|
end
|
|
88
89
|
|
|
89
90
|
def save_comparison_csv_to_file
|
|
90
|
-
compared_csv_file_name = "#{output_file_prefix}compared_results_#{results_timestamp}.csv"
|
|
91
|
+
compared_csv_file_name = "#{output_file_prefix}compared_results_#{results_timestamp}#{host_suffix}.csv"
|
|
91
92
|
File.write(File.join(output_directory, compared_csv_file_name),
|
|
92
93
|
compared_results_as_csv)
|
|
93
94
|
end
|
|
94
95
|
|
|
96
|
+
# Filesystem-safe "_<host>" (plus non-default port, if any) suffix derived from
|
|
97
|
+
# the --inferno-base-url (-I) option, so output filenames can be disambiguated
|
|
98
|
+
# by target server. Empty when -I was not passed or the URL has no host.
|
|
99
|
+
def host_suffix
|
|
100
|
+
inferno_host_slug ? "_#{inferno_host_slug}" : ''
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def inferno_host_slug
|
|
104
|
+
return nil unless options[:inferno_base_url].present?
|
|
105
|
+
|
|
106
|
+
uri = begin
|
|
107
|
+
URI.parse(options[:inferno_base_url])
|
|
108
|
+
rescue URI::InvalidURIError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
host = uri&.host
|
|
112
|
+
return nil unless host
|
|
113
|
+
|
|
114
|
+
slug = host.dup
|
|
115
|
+
slug << "_#{uri.port}" if uri.port && uri.port != uri.default_port
|
|
116
|
+
slug.gsub(/[^a-zA-Z0-9.-]/, '_')
|
|
117
|
+
end
|
|
118
|
+
|
|
95
119
|
def display_compared_results
|
|
96
120
|
output = {
|
|
97
121
|
matched: results_match?,
|
|
@@ -27,7 +27,10 @@ module Inferno
|
|
|
27
27
|
|
|
28
28
|
if session_status['id'].present?
|
|
29
29
|
run_id = session_status['id']
|
|
30
|
-
|
|
30
|
+
test_results = run_results(run_id).select { |result| result['test_id'].present? }
|
|
31
|
+
session_status['completed_test_count'] = test_results.size
|
|
32
|
+
|
|
33
|
+
last_test_executed = last_test_executed(test_results, session_status['status'])
|
|
31
34
|
if last_test_executed.present?
|
|
32
35
|
session_status['last_test_executed'] = last_test_executed['test_id']
|
|
33
36
|
if session_status['status'] == 'waiting'
|
|
@@ -53,9 +56,22 @@ module Inferno
|
|
|
53
56
|
}
|
|
54
57
|
end
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
# Serialized updated_at values can collide when results are written in
|
|
60
|
+
# quick succession, so when the run is waiting, identify the waiting
|
|
61
|
+
# test by its 'wait' result rather than by timestamp order alone.
|
|
62
|
+
def last_test_executed(test_results, run_status)
|
|
63
|
+
if run_status == 'waiting'
|
|
64
|
+
wait_results = test_results.select { |result| result['result'] == 'wait' }
|
|
65
|
+
test_results = wait_results if wait_results.any?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
most_recent_result(test_results)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# updated_at ties are broken by array position (later results win)
|
|
72
|
+
# since the API returns results in insertion order.
|
|
73
|
+
def most_recent_result(results)
|
|
74
|
+
results.each_with_index.max_by { |result, index| [result['updated_at'].to_s, index] }&.first
|
|
59
75
|
end
|
|
60
76
|
|
|
61
77
|
def run_results(run_id)
|
|
@@ -37,7 +37,14 @@ module Inferno
|
|
|
37
37
|
|
|
38
38
|
persist_inputs(session_data_repo, req.params, test_run.runnable)
|
|
39
39
|
|
|
40
|
-
Jobs.perform(
|
|
40
|
+
Jobs.perform(
|
|
41
|
+
Jobs::ExecuteTestRun,
|
|
42
|
+
test_run.id,
|
|
43
|
+
tags: [
|
|
44
|
+
"session:#{test_session.id}",
|
|
45
|
+
"run:#{test_run.test_suite_id || test_run.test_group_id || test_run.test_id}"
|
|
46
|
+
]
|
|
47
|
+
)
|
|
41
48
|
rescue Sequel::ValidationFailed, Sequel::ForeignKeyConstraintViolation,
|
|
42
49
|
Inferno::Exceptions::RequiredInputsNotFound,
|
|
43
50
|
Inferno::Exceptions::NotUserRunnableException => e
|
|
@@ -22,7 +22,16 @@ module Inferno
|
|
|
22
22
|
if test_run_is_waiting
|
|
23
23
|
waiting_result = results_repo.find_waiting_result(test_run_id: test_run.id)
|
|
24
24
|
results_repo.update_result(waiting_result.id, 'cancel', 'Test cancelled by user')
|
|
25
|
-
Jobs.perform(
|
|
25
|
+
Jobs.perform(
|
|
26
|
+
Jobs::ResumeTestRun,
|
|
27
|
+
test_run.id,
|
|
28
|
+
tags: [
|
|
29
|
+
'source:delete',
|
|
30
|
+
"session:#{test_run.test_session_id}",
|
|
31
|
+
"run:#{test_run.test_suite_id || test_run.test_group_id || test_run.test_id}",
|
|
32
|
+
"test:#{waiting_result.test_id}"
|
|
33
|
+
]
|
|
34
|
+
)
|
|
26
35
|
end
|
|
27
36
|
|
|
28
37
|
res.status = 204
|
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
require 'sequel'
|
|
2
2
|
require 'erb'
|
|
3
3
|
|
|
4
|
+
module Inferno
|
|
5
|
+
module Config
|
|
6
|
+
module Boot
|
|
7
|
+
# Extracted from the :db provider below so the sqlite-only branching can
|
|
8
|
+
# be unit tested with plain config hashes/doubles, instead of requiring
|
|
9
|
+
# a real postgres connection (which this project has no infrastructure
|
|
10
|
+
# for) just to exercise the non-sqlite path.
|
|
11
|
+
module Db
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# The web and worker processes each hold their own pool of connections to the
|
|
15
|
+
# same sqlite file, so writes from one process can easily collide with reads
|
|
16
|
+
# or writes from another. WAL mode lets readers proceed without blocking on a
|
|
17
|
+
# concurrent writer, and a longer busy_timeout gives a blocked writer more
|
|
18
|
+
# room to wait its turn instead of immediately raising SQLITE_BUSY.
|
|
19
|
+
#
|
|
20
|
+
# If you change this, run `bundle exec rake db:check_concurrency` to verify
|
|
21
|
+
# SQLITE_BUSY is still avoided under concurrent test-run writes and status
|
|
22
|
+
# polling (see lib/inferno/utils/db_concurrency_check.rb for why that's a
|
|
23
|
+
# rake task and not a spec).
|
|
24
|
+
def configure_sqlite_pragmas!(config)
|
|
25
|
+
return config unless config['adapter'] == 'sqlite'
|
|
26
|
+
|
|
27
|
+
connect_sqls = ["PRAGMA busy_timeout = #{ENV.fetch('DB_BUSY_TIMEOUT_MS', '15000')}"]
|
|
28
|
+
connect_sqls << 'PRAGMA journal_mode = WAL' unless config['database'] == ':memory:'
|
|
29
|
+
config['connect_sqls'] = connect_sqls
|
|
30
|
+
config
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def log_sqlite_journal_mode(config, connection, logger)
|
|
34
|
+
return unless config['adapter'] == 'sqlite'
|
|
35
|
+
|
|
36
|
+
actual_journal_mode = connection.fetch('PRAGMA journal_mode').first[:journal_mode]
|
|
37
|
+
logger.info("sqlite journal_mode: #{actual_journal_mode}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
4
44
|
Inferno::Application.register_provider(:db) do
|
|
5
45
|
prepare do
|
|
6
46
|
target_container.start :logging
|
|
@@ -13,6 +53,9 @@ Inferno::Application.register_provider(:db) do
|
|
|
13
53
|
config_contents = ERB.new(File.read(config_path)).result
|
|
14
54
|
config = YAML.safe_load(config_contents)[ENV.fetch('APP_ENV', nil)]
|
|
15
55
|
.merge(logger: Inferno::Application['logger'])
|
|
56
|
+
|
|
57
|
+
Inferno::Config::Boot::Db.configure_sqlite_pragmas!(config)
|
|
58
|
+
|
|
16
59
|
connection_attempts_remaining = ENV.fetch('MAX_DB_CONNECTION_ATTEMPTS', '10').to_i
|
|
17
60
|
connection_retry_delay = ENV.fetch('DB_CONNECTION_RETRY_DELAY', '5').to_i
|
|
18
61
|
connection = nil
|
|
@@ -31,6 +74,8 @@ Inferno::Application.register_provider(:db) do
|
|
|
31
74
|
end
|
|
32
75
|
connection.sql_log_level = :debug
|
|
33
76
|
|
|
77
|
+
Inferno::Config::Boot::Db.log_sqlite_journal_mode(config, connection, Inferno::Application['logger'])
|
|
78
|
+
|
|
34
79
|
register('db.config', config)
|
|
35
80
|
register('db.connection', connection)
|
|
36
81
|
end
|
|
@@ -7,6 +7,10 @@ Inferno::Application.register_provider(:executor) do
|
|
|
7
7
|
|
|
8
8
|
Blueprinter.configure do |config|
|
|
9
9
|
config.generator = Oj
|
|
10
|
+
# Oj >= 3.17 no longer honors ActiveSupport's Time#to_json, which
|
|
11
|
+
# dropped sub-second precision from serialized timestamps. Clients
|
|
12
|
+
# order results by these strings, so keep millisecond precision.
|
|
13
|
+
config.datetime_format = '%FT%T.%L%:z'
|
|
10
14
|
end
|
|
11
15
|
|
|
12
16
|
target_container.start :suites
|
|
@@ -5,6 +5,10 @@ Inferno::Application.register_provider(:web) do |_app|
|
|
|
5
5
|
|
|
6
6
|
Blueprinter.configure do |config|
|
|
7
7
|
config.generator = Oj
|
|
8
|
+
# Oj >= 3.17 no longer honors ActiveSupport's Time#to_json, which
|
|
9
|
+
# dropped sub-second precision from serialized timestamps. Clients
|
|
10
|
+
# order results by these strings, so keep millisecond precision.
|
|
11
|
+
config.datetime_format = '%FT%T.%L%:z'
|
|
8
12
|
end
|
|
9
13
|
|
|
10
14
|
# Workers aren't connected to a web server, so they shouldn't be hosting
|
|
@@ -42,8 +42,13 @@ module Inferno
|
|
|
42
42
|
# @param given_element [FHIR::Model, Array<FHIR::Model>]
|
|
43
43
|
# @param path [String]
|
|
44
44
|
# @param include_dar [Boolean]
|
|
45
|
+
# @param recursive_segments [Array<String>] names of path segments that are
|
|
46
|
+
# self-referential (eg. `item` in Questionnaire.item.item, via a FHIR
|
|
47
|
+
# `contentReference`). When a segment matches, the search also continues
|
|
48
|
+
# into deeper repeats of that segment (item.item.item...) if the value
|
|
49
|
+
# isn't found at the literal path depth.
|
|
45
50
|
# @return a single matching value (which can include `false`) or `nil` if not found
|
|
46
|
-
def find_a_value_at(given_element, path, include_dar: false, &block)
|
|
51
|
+
def find_a_value_at(given_element, path, include_dar: false, recursive_segments: [], &block)
|
|
47
52
|
return nil if given_element.nil?
|
|
48
53
|
|
|
49
54
|
elements = Array.wrap(given_element)
|
|
@@ -56,8 +61,13 @@ module Inferno
|
|
|
56
61
|
remaining_path = path_segments.join('.')
|
|
57
62
|
elements.each do |element|
|
|
58
63
|
child = get_next_value(element, segment)
|
|
59
|
-
element_found = find_a_value_at(child, remaining_path, include_dar:, &block)
|
|
64
|
+
element_found = find_a_value_at(child, remaining_path, include_dar:, recursive_segments:, &block)
|
|
60
65
|
return element_found if value_not_empty?(element_found)
|
|
66
|
+
|
|
67
|
+
next unless recursive_segments.include?(segment)
|
|
68
|
+
|
|
69
|
+
nested_found = find_a_value_at(child, path, include_dar:, recursive_segments:, &block)
|
|
70
|
+
return nested_found if value_not_empty?(nested_found)
|
|
61
71
|
end
|
|
62
72
|
|
|
63
73
|
nil
|
|
@@ -26,6 +26,7 @@ module Inferno
|
|
|
26
26
|
# allowExampleUrls true
|
|
27
27
|
# txServer nil
|
|
28
28
|
# end
|
|
29
|
+
# expansion_parameters 'path/to/expansion_parameters.json'
|
|
29
30
|
# end
|
|
30
31
|
module FHIRResourceValidation
|
|
31
32
|
def self.included(klass)
|
|
@@ -118,6 +119,99 @@ module Inferno
|
|
|
118
119
|
|
|
119
120
|
alias cli_context validation_context
|
|
120
121
|
|
|
122
|
+
# Environment variable containing the default expansion parameters.
|
|
123
|
+
# Used by {#expansion_parameters} when no value has been set
|
|
124
|
+
# explicitly. May contain either the raw JSON content of a FHIR
|
|
125
|
+
# Parameters resource (if it starts with `{`) or a path to a file
|
|
126
|
+
# containing one.
|
|
127
|
+
EXPANSION_PARAMETERS_ENV_VAR = 'FHIR_RESOURCE_VALIDATOR_EXPANSION_PARAMETERS'.freeze
|
|
128
|
+
|
|
129
|
+
# Set the expansion parameters to be sent with each validation
|
|
130
|
+
# request. This configures how the validator's terminology engine
|
|
131
|
+
# expands value sets during validation (e.g. designation
|
|
132
|
+
# preferences, forcing the use of the latest terminology versions,
|
|
133
|
+
# etc.). The content is sent inline with every validation request
|
|
134
|
+
# made by this validator, since the validator does not have access
|
|
135
|
+
# to Inferno's filesystem.
|
|
136
|
+
#
|
|
137
|
+
# Accepts either a Hash containing the contents of a FHIR Parameters
|
|
138
|
+
# resource, or a String path to a file (JSON or XML) containing one.
|
|
139
|
+
# The file is read once, the first time it's needed.
|
|
140
|
+
#
|
|
141
|
+
# If never set explicitly, this falls back to the
|
|
142
|
+
# `FHIR_RESOURCE_VALIDATOR_EXPANSION_PARAMETERS` environment
|
|
143
|
+
# variable, if present. This allows a shared set of expansion
|
|
144
|
+
# parameters to be configured once for every test kit that uses a
|
|
145
|
+
# given validator instance, while still letting individual test
|
|
146
|
+
# kits opt out or override it by calling this method themselves.
|
|
147
|
+
# The environment variable's content is treated as raw JSON if it
|
|
148
|
+
# starts with `{`, and otherwise as a file path.
|
|
149
|
+
#
|
|
150
|
+
# @example
|
|
151
|
+
# # Passing a Hash
|
|
152
|
+
# fhir_resource_validator do
|
|
153
|
+
# url 'http://example.com/validator'
|
|
154
|
+
# expansion_parameters({
|
|
155
|
+
# resourceType: 'Parameters',
|
|
156
|
+
# parameter: [{ name: 'excludeNested', valueBoolean: true }]
|
|
157
|
+
# })
|
|
158
|
+
# end
|
|
159
|
+
#
|
|
160
|
+
# @example
|
|
161
|
+
# # Passing a file path
|
|
162
|
+
# fhir_resource_validator do
|
|
163
|
+
# url 'http://example.com/validator'
|
|
164
|
+
# expansion_parameters 'path/to/expansion_parameters.json'
|
|
165
|
+
# end
|
|
166
|
+
#
|
|
167
|
+
# @param value [Hash, String, nil] contents of a Parameters resource
|
|
168
|
+
# as a Hash, or a path to a file (JSON or XML) containing one
|
|
169
|
+
def expansion_parameters(value = nil)
|
|
170
|
+
if value
|
|
171
|
+
@expansion_parameters = build_expansion_parameters(value)
|
|
172
|
+
elsif !@expansion_parameters_resolved
|
|
173
|
+
env_value = ENV.fetch(EXPANSION_PARAMETERS_ENV_VAR, nil)
|
|
174
|
+
@expansion_parameters = build_expansion_parameters_from_env(env_value) if env_value
|
|
175
|
+
end
|
|
176
|
+
@expansion_parameters_resolved = true
|
|
177
|
+
|
|
178
|
+
@expansion_parameters
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @private
|
|
182
|
+
# Determines whether the environment variable's content is raw JSON
|
|
183
|
+
# or a file path based on its first non-whitespace character.
|
|
184
|
+
def build_expansion_parameters_from_env(env_value)
|
|
185
|
+
if env_value.lstrip.start_with?('{')
|
|
186
|
+
build_expansion_parameters_from_json_content(env_value)
|
|
187
|
+
else
|
|
188
|
+
build_expansion_parameters(env_value)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# @private
|
|
193
|
+
def build_expansion_parameters(value)
|
|
194
|
+
case value
|
|
195
|
+
when Hash
|
|
196
|
+
build_expansion_parameters_from_json_content(value.to_json)
|
|
197
|
+
else
|
|
198
|
+
{
|
|
199
|
+
fileName: File.basename(value),
|
|
200
|
+
fileContent: File.read(value),
|
|
201
|
+
fileType: nil
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# @private
|
|
207
|
+
def build_expansion_parameters_from_json_content(json_content)
|
|
208
|
+
{
|
|
209
|
+
fileName: 'expansion_parameters.json',
|
|
210
|
+
fileContent: json_content,
|
|
211
|
+
fileType: nil
|
|
212
|
+
}
|
|
213
|
+
end
|
|
214
|
+
|
|
121
215
|
# @private
|
|
122
216
|
# Used internally by perform_additional_validation
|
|
123
217
|
def additional_validations
|
|
@@ -622,6 +716,8 @@ module Inferno
|
|
|
622
716
|
],
|
|
623
717
|
sessionId: @session_id
|
|
624
718
|
}
|
|
719
|
+
wrapped_resource[:expansionParameters] = expansion_parameters if expansion_parameters
|
|
720
|
+
|
|
625
721
|
wrapped_resource.to_json
|
|
626
722
|
end
|
|
627
723
|
end
|
|
@@ -193,14 +193,16 @@ module Inferno
|
|
|
193
193
|
inputs[input.name.to_sym] = Entities::Input.new(**input.to_hash)
|
|
194
194
|
end
|
|
195
195
|
|
|
196
|
+
# children_available_inputs walks the entire subtree, so it is computed once here
|
|
197
|
+
# rather than once per input. merge_with_child only mutates its receiver, so the
|
|
198
|
+
# child definitions are safe to reuse for the merge below.
|
|
199
|
+
child_inputs = children_available_inputs(selected_suite_options)
|
|
200
|
+
|
|
196
201
|
available_inputs.each do |input, current_definition|
|
|
197
|
-
|
|
198
|
-
current_definition.merge_with_child(child_definition)
|
|
202
|
+
current_definition.merge_with_child(child_inputs[input])
|
|
199
203
|
end
|
|
200
204
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
order_available_inputs(available_inputs)
|
|
205
|
+
order_available_inputs(child_inputs.merge(available_inputs))
|
|
204
206
|
end
|
|
205
207
|
end
|
|
206
208
|
end
|
|
@@ -15,10 +15,14 @@ module Inferno
|
|
|
15
15
|
# @param resources [Array<FHIR::Resource>]
|
|
16
16
|
# @param profile_url [String]
|
|
17
17
|
# @param validator_name [Symbol] Name of the FHIR Validator that references the IG the profile is in
|
|
18
|
-
# @param metadata [
|
|
19
|
-
# if provided the check will use this instead of re-generating metadata from the profile
|
|
18
|
+
# @param metadata [Inferno::DSL::ProfileMetadata, #must_supports] MustSupport Metadata (optional),
|
|
19
|
+
# if provided the check will use this instead of re-generating metadata from the profile.
|
|
20
|
+
# Must respond to `#must_supports`, returning a Hash with `:elements`, `:extensions`, and
|
|
21
|
+
# `:slices` keys (and optionally `:choices` and `:recursive_elements`) -- the shape produced by
|
|
22
|
+
# {MustSupportMetadataExtractor#must_supports}. {Inferno::DSL::ProfileMetadata} is a base class
|
|
23
|
+
# test kits can subclass to build these objects, eg from generated YAML, instead of hand-rolling one.
|
|
20
24
|
# @param requirement_extension [String] Extension URL that implies "required" as an alternative to the MS flag
|
|
21
|
-
# @yield [
|
|
25
|
+
# @yield [MustSupportMetadataExtractor] Customize the metadata before running the test
|
|
22
26
|
# @return [Array<String>] List of missing elements
|
|
23
27
|
def missing_must_support_elements(resources, profile_url, validator_name: :default, metadata: nil,
|
|
24
28
|
requirement_extension: nil, &)
|
|
@@ -199,6 +203,12 @@ module Inferno
|
|
|
199
203
|
metadata.must_supports[:extensions]
|
|
200
204
|
end
|
|
201
205
|
|
|
206
|
+
# Names of path segments that are self-referential (eg 'item' in Questionnaire.item.item)
|
|
207
|
+
# and so should be searched at any depth of nesting, not just the literal depth of a path.
|
|
208
|
+
def recursive_element_segments
|
|
209
|
+
Array.wrap(metadata.must_supports[:recursive_elements])
|
|
210
|
+
end
|
|
211
|
+
|
|
202
212
|
def missing_extensions(resources = [])
|
|
203
213
|
@missing_extensions ||=
|
|
204
214
|
must_support_extensions.select do |extension_definition|
|
|
@@ -212,7 +222,7 @@ module Inferno
|
|
|
212
222
|
normalized_extension_url(extension.url) == expected_url
|
|
213
223
|
end
|
|
214
224
|
else
|
|
215
|
-
extension = find_a_value_at(resource, path) do |el|
|
|
225
|
+
extension = find_a_value_at(resource, path, recursive_segments: recursive_element_segments) do |el|
|
|
216
226
|
normalized_extension_url(el.url) == expected_url
|
|
217
227
|
end
|
|
218
228
|
|
|
@@ -243,9 +253,10 @@ module Inferno
|
|
|
243
253
|
ms_extension_urls = must_support_extensions.select { |ex| ex[:path] == "#{raw_path}.extension" }
|
|
244
254
|
.map { |ex| ex[:url] }
|
|
245
255
|
|
|
246
|
-
value_found =
|
|
247
|
-
|
|
248
|
-
|
|
256
|
+
value_found =
|
|
257
|
+
find_a_value_at(resource, path, recursive_segments: recursive_element_segments) do |potential_value|
|
|
258
|
+
matching_without_extensions?(potential_value, ms_extension_urls, element_definition[:fixed_value])
|
|
259
|
+
end
|
|
249
260
|
|
|
250
261
|
# Note that false.present? => false, which is why we need to add this extra check
|
|
251
262
|
value_found.present? || value_found == false
|
|
@@ -369,7 +380,7 @@ module Inferno
|
|
|
369
380
|
# TODO: there is a lot of similarity
|
|
370
381
|
# between this and FHIRResourceNavigation.matching_slice?
|
|
371
382
|
# Can these be combined?
|
|
372
|
-
find_a_value_at(resource, path) do |element|
|
|
383
|
+
find_a_value_at(resource, path, recursive_segments: recursive_element_segments) do |element|
|
|
373
384
|
case discriminator[:type]
|
|
374
385
|
when 'patternCodeableConcept'
|
|
375
386
|
find_pattern_codeable_concept_slice(element, discriminator)
|
|
@@ -24,16 +24,49 @@ module Inferno
|
|
|
24
24
|
self.requirement_extension_url = requirement_extension_url
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# @return [String] the canonical URL of the profile
|
|
28
|
+
def profile_url
|
|
29
|
+
profile.url
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [String] the human-readable name of the profile, eg for display purposes.
|
|
33
|
+
# Collapses any runs of repeated whitespace, since profile titles occasionally have them.
|
|
34
|
+
def profile_name
|
|
35
|
+
profile.title&.squeeze(' ')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [String] the version of the profile itself (distinct from the IG's own version)
|
|
39
|
+
def profile_version
|
|
40
|
+
profile.version
|
|
41
|
+
end
|
|
42
|
+
|
|
27
43
|
# Retrieval method for the must support metadata
|
|
28
44
|
# @return [Hash]
|
|
29
45
|
def must_supports
|
|
30
46
|
@must_supports ||= {
|
|
31
47
|
extensions: must_support_extensions,
|
|
32
48
|
slices: must_support_slices,
|
|
33
|
-
elements: must_support_elements
|
|
49
|
+
elements: must_support_elements,
|
|
50
|
+
recursive_elements: recursive_element_segments
|
|
34
51
|
}
|
|
35
52
|
end
|
|
36
53
|
|
|
54
|
+
# Names of path segments that are self-referential, ie, they may repeat at
|
|
55
|
+
# arbitrary depth. FHIR marks this with a `contentReference` back to an
|
|
56
|
+
# ancestor element instead of re-declaring the element's children, eg,
|
|
57
|
+
# Questionnaire.item.item has a contentReference of "#Questionnaire.item".
|
|
58
|
+
# A MustSupport flag on an element under such a segment (eg Questionnaire.item.text)
|
|
59
|
+
# should be considered met if it's populated at any depth of nesting
|
|
60
|
+
# (item.text, item.item.text, item.item.item.text, ...), not just the literal depth
|
|
61
|
+
# at which the flag is declared. NOTE: does not currently handle sliced recursive elements.
|
|
62
|
+
# @return [Array<String>]
|
|
63
|
+
def recursive_element_segments
|
|
64
|
+
profile_elements
|
|
65
|
+
.select { |element| element.contentReference.present? }
|
|
66
|
+
.map { |element| element.id.split('.').last }
|
|
67
|
+
.uniq
|
|
68
|
+
end
|
|
69
|
+
|
|
37
70
|
def by_requirement_extension_only?(element)
|
|
38
71
|
requirement_extension_url && !element.mustSupport &&
|
|
39
72
|
element.extension.any? do |extension|
|
|
@@ -53,6 +86,7 @@ module Inferno
|
|
|
53
86
|
must_support_extension_elements.map do |element|
|
|
54
87
|
{
|
|
55
88
|
id: element.id,
|
|
89
|
+
slice_name: extension_slice_name(element.id),
|
|
56
90
|
path: element.path.gsub("#{resource}.", ''),
|
|
57
91
|
url: canonical_url_without_version(element.type.first.profile.first),
|
|
58
92
|
modifier_extension: element.path.end_with?('modifierExtension')
|
|
@@ -62,6 +96,15 @@ module Inferno
|
|
|
62
96
|
end
|
|
63
97
|
end
|
|
64
98
|
|
|
99
|
+
# The name of the deepest slice in a FHIR ElementDefinition id, eg "us-core-race" for
|
|
100
|
+
# "Patient.extension:us-core-race" or "ombCategory" for
|
|
101
|
+
# "Patient.extension:us-core-race.extension:ombCategory".
|
|
102
|
+
# @param element_id [String]
|
|
103
|
+
# @return [String, nil]
|
|
104
|
+
def extension_slice_name(element_id)
|
|
105
|
+
element_id.split('.').reverse.find { |segment| segment.include?(':') }&.split(':', 2)&.last
|
|
106
|
+
end
|
|
107
|
+
|
|
65
108
|
def canonical_url_without_version(url)
|
|
66
109
|
url&.split('|')&.first
|
|
67
110
|
end
|