inferno_core 1.4.2 → 1.4.4

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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/lib/inferno/apps/cli/execute_script.rb +8 -4
  3. data/lib/inferno/apps/cli/main.rb +46 -5
  4. data/lib/inferno/apps/cli/requirements_coverage_checker.rb +1 -1
  5. data/lib/inferno/apps/cli/templates/Rakefile.tt +0 -13
  6. data/lib/inferno/apps/cli/templates/config/nginx.background.conf.tt +0 -30
  7. data/lib/inferno/apps/cli/templates/config/nginx.conf.tt +0 -30
  8. data/lib/inferno/apps/cli/templates/docker-compose.background.yml.tt +1 -14
  9. data/lib/inferno/apps/cli/templates/docker-compose.yml.tt +1 -10
  10. data/lib/inferno/apps/cli/templates/execution_scripts/{README.md.tt → README.md} +3 -3
  11. data/lib/inferno/apps/cli/templates/lib/%library_name%/requirements/%library_name%_requirements.csv +3 -0
  12. data/lib/inferno/apps/cli/templates/lib/%library_name%/requirements/Inferno Requirements Template.xlsx +0 -0
  13. data/lib/inferno/apps/cli/templates/lib/%library_name%/requirements/README.md +12 -0
  14. data/lib/inferno/apps/cli/templates/lib/%library_name%/requirements/generated/%test_suite_id%_requirements_coverage.csv.tt +1 -0
  15. data/lib/inferno/dsl/fhir_resource_navigation.rb +12 -2
  16. data/lib/inferno/dsl/fhir_resource_validation.rb +186 -4
  17. data/lib/inferno/dsl/must_support_assessment.rb +19 -8
  18. data/lib/inferno/dsl/must_support_metadata_extractor.rb +44 -1
  19. data/lib/inferno/dsl/profile_metadata.rb +113 -0
  20. data/lib/inferno/dsl/resume_test_route.rb +4 -1
  21. data/lib/inferno/dsl/suite_endpoint.rb +164 -6
  22. data/lib/inferno/dsl.rb +1 -0
  23. data/lib/inferno/entities/ig.rb +51 -8
  24. data/lib/inferno/public/bundle.js +34 -34
  25. data/lib/inferno/public/bundle.js.LICENSE.txt +0 -2
  26. data/lib/inferno/test_runner.rb +55 -40
  27. data/lib/inferno/utils/execution_script_runner.rb +56 -7
  28. data/lib/inferno/version.rb +1 -1
  29. data/spec/spec_helper.rb +18 -0
  30. metadata +7 -4
  31. data/lib/inferno/apps/cli/templates/lib/%library_name%/suite.rb.tt +0 -59
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f1318358aa5a9dfd3f856740eb3b02ca30c5a960893595e44631fea414906936
4
- data.tar.gz: ddf3a22b024d5745efeead73eacc55a23d7c0b9841e57177df877ed46457dc3a
3
+ metadata.gz: c8c3149fd821b0530f7481d6a01bdef6da32263f1cca74f7b852567edb67af43
4
+ data.tar.gz: df8cd12ccb8054d182655c7537c730cd0a1f6fa538552e98171b93c43300d17b
5
5
  SHA512:
6
- metadata.gz: 5ddfa620841f6acb416190f683235fefeb089e623f238578ac7cd0022bc01ece6aee0644bae93b6d9f4ee72aff33fa4cefeda871a9a160502cd26dde0b193260
7
- data.tar.gz: 718dcda3cd89f1f9d673eaa7596ec5dc8b4dd405b9434c6a55eb2caf381d2cd1516614098ac565d1104c410991ec6496b5e11ecfaf66df77b4d49df1ae5c569f
6
+ metadata.gz: f6d6c9b29f44b0f3169bee590aac204520aa3fc24d651443b5136958cecfffc889d71172881b4c97323b6b17b65d78afd8fd8027a0b8d933037023345fed975d
7
+ data.tar.gz: 196c88889027e64aa05e3e7a9997749c9dfc5fab9fecdfa1a40437005d2cfd9a5390945f3efc3382ae4c2a3ea3f208ebc5720b5837ca1ad46fd5f08dfdab8678
@@ -138,7 +138,7 @@ module Inferno
138
138
 
139
139
  ExecutionStatus = Struct.new(
140
140
  :done, :failed, :timed_out, :cancel_pending, :current_session, :current_timeout, :last_log_time,
141
- :cross_session_status, :last_step_signatures
141
+ :poll_start_time, :cross_session_status, :last_step_signatures
142
142
  )
143
143
 
144
144
  attr_accessor :yaml_file, :options, :execution_status
@@ -155,6 +155,7 @@ module Inferno
155
155
  cancel_pending: false,
156
156
  current_session: sessions.first,
157
157
  current_timeout: options[:default_poll_timeout],
158
+ poll_start_time: nil,
158
159
  cross_session_status: {},
159
160
  last_step_signatures: {}
160
161
  )
@@ -416,6 +417,7 @@ module Inferno
416
417
  warn ''
417
418
  warn "Polling session: #{session.key} (#{session.session_id}) timeout=#{timeout}s"
418
419
  deadline = Time.now + timeout
420
+ execution_status.poll_start_time = Time.now
419
421
  execution_status.last_log_time = Time.now - LOG_INTERVAL_SECONDS
420
422
 
421
423
  loop do
@@ -518,12 +520,14 @@ module Inferno
518
520
  last_completed = last_completed_from_status(status)
519
521
  poll_status_last_test =
520
522
  last_completed.present? ? " - last test: #{format_last_completed(last_completed, session_key)}" : ''
521
- warn " [#{session_key}] #{status['status']} (#{test_progress(status)})#{poll_status_last_test}"
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}"
522
526
  execution_status.last_log_time = Time.now
523
527
  end
524
528
 
525
- def test_progress(status)
526
- "#{status['completed_test_count']}/#{status['test_count']} tests"
529
+ def test_progress(status, elapsed)
530
+ "completed #{status['completed_test_count']}/#{status['test_count']} tests in #{elapsed}s"
527
531
  end
528
532
 
529
533
  def fetch_session_status(session_id)
@@ -9,6 +9,7 @@ require_relative 'suites'
9
9
  require_relative 'new'
10
10
  require_relative 'execute'
11
11
  require_relative 'execute_script'
12
+ require_relative '../../utils/execution_script_runner'
12
13
  require_relative '../../version'
13
14
 
14
15
  module Inferno
@@ -78,8 +79,31 @@ module Inferno
78
79
  desc 'requirements SUBCOMMAND ...ARGS', 'Perform requirements operations'
79
80
  subcommand 'requirements', Requirements
80
81
 
81
- desc 'execute_script YAML_FILE',
82
- 'Run a session orchestration script defined by a YAML config file.'
82
+ desc 'execute_script [PATTERN]',
83
+ 'Run one or more session orchestration scripts defined by YAML config file(s).'
84
+ long_desc <<-LONGDESC
85
+ Run a session orchestration script defined by a YAML config file.
86
+
87
+ PATTERN may be a single YAML file, or it may contain wildcards (e.g. glob
88
+ patterns like `*` or `**`) to match and run multiple scripts in sequence.
89
+ When PATTERN matches more than one file, each matching script is run in a
90
+ separate process; a summary of passed and failed scripts is printed at the
91
+ end, and the command exits non-zero if any script failed.
92
+
93
+ If PATTERN is omitted, it defaults to `execution_scripts/**/*.yaml`, running
94
+ every script under the execution_scripts directory.
95
+
96
+ Examples:
97
+
98
+ # Run every script under execution_scripts
99
+ `bundle exec inferno execute_script`
100
+
101
+ # Run a single script
102
+ `bundle exec inferno execute_script execution_scripts/demo/demo_individual_tests.yaml`
103
+
104
+ # Run every script in a directory
105
+ `bundle exec inferno execute_script "execution_scripts/demo/*.yaml"`
106
+ LONGDESC
83
107
  option :inferno_base_url,
84
108
  aliases: ['-I'],
85
109
  type: :string,
@@ -113,9 +137,26 @@ module Inferno
113
137
  type: :boolean,
114
138
  default: false,
115
139
  desc: 'Allow execution script steps that run arbitrary shell commands. ' \
116
- 'Scripts with command: steps will fail unless this flag is set.'
117
- def execute_script(yaml_file)
118
- ExecuteScript.new(yaml_file, options).run
140
+ 'Scripts with command: steps will fail unless this flag is set. ' \
141
+ 'When PATTERN matches multiple scripts, this also applies to all of them ' \
142
+ '(in addition to any individual script whose filename contains "_with_commands").'
143
+ def execute_script(pattern = 'execution_scripts/**/*.yaml')
144
+ matches = Dir.glob(pattern).select { |file| file.end_with?('.yaml', '.yml') }.sort
145
+
146
+ if matches.length > 1 || (matches.empty? && pattern.match?(/[*?\[\]{}]/))
147
+ Utils::ExecutionScriptRunner.run_all(
148
+ pattern:,
149
+ inferno_base_url: options[:inferno_base_url],
150
+ allow_commands: options[:allow_commands],
151
+ compare_messages: options[:compare_messages],
152
+ compare_result_message: options[:compare_result_message],
153
+ poll_interval: options[:poll_interval],
154
+ default_poll_timeout: options[:default_poll_timeout],
155
+ only_different_messages: options[:only_different_messages]
156
+ )
157
+ else
158
+ ExecuteScript.new(matches.first || pattern, options).run
159
+ end
119
160
  end
120
161
 
121
162
  desc 'session SUBCOMMAND ...ARGS', 'Perform session operations'
@@ -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
@@ -5,19 +5,6 @@ begin
5
5
  rescue LoadError # rubocop:disable Lint/SuppressedException
6
6
  end
7
7
 
8
- namespace :execute_scripts do
9
- desc 'Run all execution script YAML files against a local Inferno instance (already running). ' \
10
- 'Optional FILTER env var restricts by File.fnmatch pattern, e.g. FILTER="execution_scripts/demo/*". ' \
11
- 'Optional INFERNO_BASE_URL env var sets the target Inferno URL, e.g. INFERNO_BASE_URL="http://localhost:4567/"'
12
- task :run_all do
13
- require 'inferno/utils/execution_script_runner'
14
- Inferno::Utils::ExecutionScriptRunner.run_all(
15
- pattern: ENV.fetch('FILTER', 'execution_scripts/**/*.yaml'),
16
- inferno_base_url: ENV.fetch('INFERNO_BASE_URL', nil)
17
- )
18
- end
19
- end
20
-
21
8
  namespace :db do
22
9
  desc 'Apply changes to the database'
23
10
  task :migrate do
@@ -53,36 +53,6 @@ http {
53
53
  # the server will close connections after this time
54
54
  keepalive_timeout 600;
55
55
 
56
- location /validator {
57
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
58
- proxy_set_header Host $http_host;
59
- proxy_set_header X-Forwarded-Proto $scheme;
60
- proxy_set_header X-Forwarded-Port $server_port;
61
- proxy_redirect off;
62
- proxy_set_header Connection '';
63
- proxy_http_version 1.1;
64
- chunked_transfer_encoding off;
65
- proxy_buffering off;
66
- proxy_cache off;
67
-
68
- proxy_pass http://fhir_validator_app;
69
- }
70
-
71
- location /validatorapi/ {
72
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
73
- proxy_set_header Host $http_host;
74
- proxy_set_header X-Forwarded-Proto $scheme;
75
- proxy_set_header X-Forwarded-Port $server_port;
76
- proxy_redirect off;
77
- proxy_set_header Connection '';
78
- proxy_http_version 1.1;
79
- chunked_transfer_encoding off;
80
- proxy_buffering off;
81
- proxy_cache off;
82
-
83
- proxy_pass http://validator_service:4567/;
84
- }
85
-
86
56
  location /hl7validatorapi/ {
87
57
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
88
58
  proxy_set_header Host $http_host;
@@ -68,36 +68,6 @@ http {
68
68
  proxy_pass http://inferno:4567;
69
69
  }
70
70
 
71
- location /validator {
72
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
73
- proxy_set_header Host $http_host;
74
- proxy_set_header X-Forwarded-Proto $scheme;
75
- proxy_set_header X-Forwarded-Port $server_port;
76
- proxy_redirect off;
77
- proxy_set_header Connection '';
78
- proxy_http_version 1.1;
79
- chunked_transfer_encoding off;
80
- proxy_buffering off;
81
- proxy_cache off;
82
-
83
- proxy_pass http://fhir_validator_app;
84
- }
85
-
86
- location /validatorapi/ {
87
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
88
- proxy_set_header Host $http_host;
89
- proxy_set_header X-Forwarded-Proto $scheme;
90
- proxy_set_header X-Forwarded-Port $server_port;
91
- proxy_redirect off;
92
- proxy_set_header Connection '';
93
- proxy_http_version 1.1;
94
- chunked_transfer_encoding off;
95
- proxy_buffering off;
96
- proxy_cache off;
97
-
98
- proxy_pass http://validator_service:4567/;
99
- }
100
-
101
71
  location /hl7validatorapi/ {
102
72
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
103
73
  proxy_set_header Host $http_host;
@@ -1,4 +1,3 @@
1
- version: '3'
2
1
  services:
3
2
  hl7_validator_service:
4
3
  image: infernocommunity/inferno-resource-validator
@@ -11,18 +10,6 @@ services:
11
10
  # To let the service share your local FHIR package cache,
12
11
  # uncomment the below line
13
12
  # - ~/.fhir:/home/ktor/.fhir
14
- validator_service:
15
- image: infernocommunity/fhir-validator-service
16
- # Update this path to match your directory structure
17
- volumes:
18
- - ./data/igs:/home/igs
19
- fhir_validator_app:
20
- image: infernocommunity/fhir-validator-app
21
- depends_on:
22
- - validator_service
23
- environment:
24
- EXTERNAL_VALIDATOR_URL: http://localhost/validatorapi
25
- VALIDATOR_BASE_PATH: /validator
26
13
  fhirpath:
27
14
  image: infernocommunity/fhirpath-service
28
15
  ports:
@@ -35,7 +22,7 @@ services:
35
22
  - "80:80"
36
23
  command: [nginx, '-g', 'daemon off;']
37
24
  depends_on:
38
- - fhir_validator_app
25
+ - hl7_validator_service
39
26
  - fhirpath
40
27
  redis:
41
28
  image: redis
@@ -1,4 +1,3 @@
1
- version: '3'
2
1
  services:
3
2
  inferno:
4
3
  build:
@@ -6,7 +5,7 @@ services:
6
5
  volumes:
7
6
  - ./data:/opt/inferno/data
8
7
  depends_on:
9
- - validator_service
8
+ - hl7_validator_service
10
9
  worker:
11
10
  build:
12
11
  context: ./
@@ -19,14 +18,6 @@ services:
19
18
  extends:
20
19
  file: docker-compose.background.yml
21
20
  service: hl7_validator_service
22
- validator_service:
23
- extends:
24
- file: docker-compose.background.yml
25
- service: validator_service
26
- fhir_validator_app:
27
- extends:
28
- file: docker-compose.background.yml
29
- service: fhir_validator_app
30
21
  fhirpath:
31
22
  extends:
32
23
  file: docker-compose.background.yml
@@ -10,7 +10,7 @@ as `tx.fhir.org`.
10
10
 
11
11
  Execution scripts defined here will
12
12
  be [executed](https://inferno-framework.github.io/docs/advanced-test-features/scripting-execution.html#execution)
13
- automatically on pull requests as a part of the github
13
+ automatically on pull requests as a part of the GitHub
14
14
  workflows and can also be executed locally using the
15
- [`execute_script` CLI](https://inferno-framework.github.io/docs/getting-started/inferno-cli.html#complex-scripted-execution)
16
- on individual scripts or the `execute_scripts:run_all` rake task.
15
+ [`execute_script` CLI](https://inferno-framework.github.io/docs/getting-started/inferno-cli.html#complex-scripted-execution).
16
+ If no `.yaml` files are found under this directory, the GitHub workflow will be skipped.
@@ -0,0 +1,3 @@
1
+ Req Set,ID,URL,Requirement,Conformance,Actors,Sub-Requirement(s),Conditionality,Not Tested Reason,Not Tested Details
2
+ sample_v1,12,www.example.com/page.html#section,Requirement text SHALL go here,SHALL,Server,sample_v1@13,false,Not Tested,We've decided not to test this requirement
3
+ sample_v1,13,www.example.com/page.html#section,"This is a subsrequirement, and it SHOULD be implemented.",SHOULD,Server,,false,,
@@ -0,0 +1,12 @@
1
+ # Using Core Requirements Tools
2
+
3
+ This directory contains [requirement artifacts](https://inferno-framework.github.io/docs/advanced-test-features/requirements.html)
4
+ that contain example requirements that could be linked to tests
5
+ within this test kit and displayed within the UI as justification
6
+ for the tests.
7
+
8
+ Each `.xlsx` file defined in this directory will contribute towards the
9
+ test kit's requirements and a GitHub workflow executed automatically on pull
10
+ requests checks that these files are in sync with the generated requirement
11
+ list and coverage analysis `.csv` files. If no `.xlsx` files are found in
12
+ this directory, the GitHub workflow will be skipped.
@@ -0,0 +1 @@
1
+ Req Set,ID,URL,Requirement,Conformance,Actors,Conditionality,Not Tested Reason,Not Tested Details,<%= title_name %> Short ID(s),<%= title_name %> Full ID(s)
@@ -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)
@@ -54,6 +55,19 @@ module Inferno
54
55
  @validator_session_repo ||= Inferno::Repositories::ValidatorSessions.new
55
56
  end
56
57
 
58
+ # Environment variable that, when set to a truthy-looking value,
59
+ # enables verbose validation logging (the validationContext sent
60
+ # with each request, and the resulting issues including which were
61
+ # filtered out) through Inferno's normal application logger, tagged
62
+ # with the validator definition and, when available, the test
63
+ # session and test that triggered the request.
64
+ VALIDATOR_DEBUG_LOGGING_ENV_VAR = 'FHIR_RESOURCE_VALIDATOR_DEBUG_LOGGING'.freeze
65
+
66
+ # @private
67
+ def debug_logging_enabled?
68
+ ENV.fetch(VALIDATOR_DEBUG_LOGGING_ENV_VAR, nil).present?
69
+ end
70
+
57
71
  # Set the url of the validator service
58
72
  #
59
73
  # @param validator_url [String]
@@ -118,6 +132,129 @@ module Inferno
118
132
 
119
133
  alias cli_context validation_context
120
134
 
135
+ # Environment variable containing the default expansion parameters.
136
+ # Used by {#expansion_parameters} when no value has been set
137
+ # explicitly. May contain either the raw JSON content of a FHIR
138
+ # Parameters resource (if it starts with `{`) or a path to a file
139
+ # containing one.
140
+ EXPANSION_PARAMETERS_ENV_VAR = 'FHIR_RESOURCE_VALIDATOR_EXPANSION_PARAMETERS'.freeze
141
+
142
+ # Set the expansion parameters to be sent with each validation
143
+ # request. This configures how the validator's terminology engine
144
+ # expands value sets during validation (e.g. designation
145
+ # preferences, forcing the use of the latest terminology versions,
146
+ # etc.). The content is sent inline with every validation request
147
+ # made by this validator, since the validator does not have access
148
+ # to Inferno's filesystem.
149
+ #
150
+ # Accepts either a Hash containing the contents of a FHIR Parameters
151
+ # resource, or a String path to a file (JSON or XML) containing one.
152
+ # The file is read once, the first time it's needed.
153
+ #
154
+ # If never set explicitly, this falls back to the
155
+ # `FHIR_RESOURCE_VALIDATOR_EXPANSION_PARAMETERS` environment
156
+ # variable, if present. This allows a shared set of expansion
157
+ # parameters to be configured once for every test kit that uses a
158
+ # given validator instance, while still letting individual test
159
+ # kits opt out or override it by calling this method themselves.
160
+ # The environment variable's content is treated as raw JSON if it
161
+ # starts with `{`, and otherwise as a file path.
162
+ #
163
+ # @example
164
+ # # Passing a Hash
165
+ # fhir_resource_validator do
166
+ # url 'http://example.com/validator'
167
+ # expansion_parameters({
168
+ # resourceType: 'Parameters',
169
+ # parameter: [{ name: 'excludeNested', valueBoolean: true }]
170
+ # })
171
+ # end
172
+ #
173
+ # @example
174
+ # # Passing a file path
175
+ # fhir_resource_validator do
176
+ # url 'http://example.com/validator'
177
+ # expansion_parameters 'path/to/expansion_parameters.json'
178
+ # end
179
+ #
180
+ # @param value [Hash, String, nil] contents of a Parameters resource
181
+ # as a Hash, or a path to a file (JSON or XML) containing one
182
+ def expansion_parameters(value = nil)
183
+ if value
184
+ @expansion_parameters = build_expansion_parameters(value)
185
+ elsif !@expansion_parameters_resolved
186
+ env_value = ENV.fetch(EXPANSION_PARAMETERS_ENV_VAR, nil)
187
+ @expansion_parameters = build_expansion_parameters_from_env(env_value) if env_value
188
+ end
189
+ @expansion_parameters_resolved = true
190
+
191
+ @expansion_parameters
192
+ end
193
+
194
+ # @private
195
+ # Determines whether the environment variable's content is raw JSON
196
+ # or a file path based on its first non-whitespace character.
197
+ def build_expansion_parameters_from_env(env_value)
198
+ if env_value.lstrip.start_with?('{')
199
+ build_expansion_parameters_from_json_content(env_value)
200
+ else
201
+ build_expansion_parameters(env_value)
202
+ end
203
+ end
204
+
205
+ # @private
206
+ def build_expansion_parameters(value)
207
+ case value
208
+ when Hash
209
+ build_expansion_parameters_from_json_content(value.to_json)
210
+ else
211
+ {
212
+ fileName: File.basename(value),
213
+ fileContent: File.read(value),
214
+ fileType: nil
215
+ }
216
+ end
217
+ end
218
+
219
+ # @private
220
+ def build_expansion_parameters_from_json_content(json_content)
221
+ {
222
+ fileName: 'expansion_parameters.json',
223
+ fileContent: json_content,
224
+ fileType: nil
225
+ }
226
+ end
227
+
228
+ # Environment variable that, when set, enables terminology server
229
+ # request logging by the validator itself. Its value is sent as
230
+ # `txLog` in the validationContext of every validation request,
231
+ # telling the validator where to log the terminology server
232
+ # requests it makes. Note that the log will appear within
233
+ # the container running the validator.
234
+ TX_LOG_ENV_VAR = 'FHIR_RESOURCE_VALIDATOR_TX_LOG'.freeze
235
+
236
+ # @private
237
+ def tx_log
238
+ ENV.fetch(TX_LOG_ENV_VAR, nil).presence
239
+ end
240
+
241
+ # @private
242
+ # Builds the validationContext sent with a request for the given
243
+ # profile. Pulled out on its own (rather than inlined in
244
+ # `wrap_target_for_hl7_wrapper`) so the same, resource-content-free
245
+ # context can also be used for logging in `log_validation_result`.
246
+ #
247
+ # @param profile_url [String]
248
+ # @return [Hash]
249
+ def build_validation_context(profile_url)
250
+ context = {
251
+ **validation_context.definition,
252
+ profiles: [profile_url]
253
+ }
254
+ context[:txLog] = tx_log if tx_log
255
+ context
256
+ end
257
+
121
258
  # @private
122
259
  # Used internally by perform_additional_validation
123
260
  def additional_validations
@@ -240,6 +377,7 @@ module Inferno
240
377
 
241
378
  # 4. Mark resources as filtered
242
379
  mark_issues_for_filtering(issues)
380
+ log_validation_result(profile_url, issues, runnable) if debug_logging_enabled?
243
381
 
244
382
  # 5. Add error messages to runnable
245
383
  filtered_issues = issues.reject(&:filtered)
@@ -353,12 +491,57 @@ module Inferno
353
491
  # @private
354
492
  def call_validator(target, profile_url)
355
493
  request_body = wrap_target_for_hl7_wrapper(target, profile_url)
494
+
356
495
  Faraday.new(
357
496
  url,
358
497
  request: { timeout: 600 }
359
498
  ).post('validate', request_body, content_type: 'application/json')
360
499
  end
361
500
 
501
+ # @private
502
+ # Logs the validationContext and expansionParameters sent with a
503
+ # request together with the resulting issues (including which were
504
+ # filtered out) in a single entry, tagged with enough context to
505
+ # trace it back to the validator definition and triggering test run.
506
+ #
507
+ # Deliberately omits the resource content itself (sent separately as
508
+ # `filesToValidate`): only the small, non-PHI-bearing
509
+ # validationContext and expansionParameters are logged, not the full
510
+ # request body.
511
+ #
512
+ # @param profile_url [String] the profile URL validated against
513
+ # @param issues [Array<ValidatorIssue>] the resulting issues, already marked for filtering
514
+ # @param runnable [Object] the runnable (typically a Test) that triggered the request
515
+ def log_validation_result(profile_url, issues, runnable)
516
+ payload = {
517
+ validator_name: name,
518
+ test_suite_id: test_suite_id,
519
+ test_session_id: runnable.respond_to?(:test_session_id) ? runnable.test_session_id : nil,
520
+ test_id: runnable.id,
521
+ validation_context: build_validation_context(profile_url),
522
+ expansion_parameters:,
523
+ issues: issues.map { |issue| issue_summary(issue) }
524
+ }.compact
525
+
526
+ Application[:logger].info("FHIR validation result: #{payload.to_json}")
527
+ end
528
+
529
+ # @private
530
+ # Recursively builds a loggable summary of a validation issue,
531
+ # including nested slice_info, without any resource content.
532
+ #
533
+ # @param issue [ValidatorIssue]
534
+ # @return [Hash]
535
+ def issue_summary(issue)
536
+ {
537
+ severity: issue.severity,
538
+ location: issue.location,
539
+ message: issue.message,
540
+ filtered: issue.filtered,
541
+ slice_info: issue.slice_info.any? ? issue.slice_info.map { |nested| issue_summary(nested) } : nil
542
+ }.compact
543
+ end
544
+
362
545
  # @private
363
546
  # Post an object to the validation service for validating.
364
547
  # Returns the raw validator response body.
@@ -609,10 +792,7 @@ module Inferno
609
792
  end
610
793
 
611
794
  wrapped_resource = {
612
- context_key => {
613
- **validation_context.definition,
614
- profiles: [profile_url]
615
- },
795
+ context_key => build_validation_context(profile_url),
616
796
  filesToValidate: [
617
797
  {
618
798
  fileName: "#{profile_url.split('/').last}.json",
@@ -622,6 +802,8 @@ module Inferno
622
802
  ],
623
803
  sessionId: @session_id
624
804
  }
805
+ wrapped_resource[:expansionParameters] = expansion_parameters if expansion_parameters
806
+
625
807
  wrapped_resource.to_json
626
808
  end
627
809
  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 [Hash] MustSupport Metadata (optional),
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 [Metadata] Customize the metadata before running the test
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 = find_a_value_at(resource, path) do |potential_value|
247
- matching_without_extensions?(potential_value, ms_extension_urls, element_definition[:fixed_value])
248
- end
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)