inferno_core 1.4.3 → 1.4.5

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 (28) hide show
  1. checksums.yaml +4 -4
  2. data/lib/inferno/apps/cli/main.rb +46 -5
  3. data/lib/inferno/apps/cli/templates/%library_name%.gemspec.tt +1 -1
  4. data/lib/inferno/apps/cli/templates/.gitignore +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/%library_name%_script_expected.json.tt +0 -8
  11. data/lib/inferno/apps/cli/templates/execution_scripts/{README.md.tt → README.md} +3 -3
  12. data/lib/inferno/apps/cli/templates/lib/%library_name%/requirements/%library_name%_requirements.csv +3 -0
  13. data/lib/inferno/apps/cli/templates/lib/%library_name%/requirements/Inferno Requirements Template.xlsx +0 -0
  14. data/lib/inferno/apps/cli/templates/lib/%library_name%/requirements/README.md +12 -0
  15. data/lib/inferno/apps/cli/templates/lib/%library_name%/requirements/generated/%test_suite_id%_requirements_coverage.csv.tt +1 -0
  16. data/lib/inferno/apps/cli/templates/spec/%library_name%/patient_group_spec.rb.tt +3 -3
  17. data/lib/inferno/config/boot/suites.rb +35 -0
  18. data/lib/inferno/dsl/fhir_resource_validation.rb +90 -4
  19. data/lib/inferno/dsl/input_output_handling.rb +101 -5
  20. data/lib/inferno/dsl/must_support_metadata_extractor.rb +19 -1
  21. data/lib/inferno/entities/input.rb +57 -0
  22. data/lib/inferno/public/bundle.js +13 -13
  23. data/lib/inferno/test_runner.rb +5 -1
  24. data/lib/inferno/utils/execution_script_runner.rb +56 -7
  25. data/lib/inferno/version.rb +1 -1
  26. data/spec/spec_helper.rb +18 -0
  27. metadata +6 -4
  28. 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: 044cfcbc6d3920cfba55d0d63fbf0fb3a1440f4f42a2e45d763beb78f280306a
4
- data.tar.gz: 243c006acc71fddd3eb165a2c400a1eab4e515f8c3f4915b27cd60fc8e41e734
3
+ metadata.gz: 8cffc2d9b151af63840789863f04e8551fd82077351f85d356b5a88f103f28f2
4
+ data.tar.gz: c6687758255cf5acadb700dfd268ee65f6ab03713dbc17ec412d1240d7f352b7
5
5
  SHA512:
6
- metadata.gz: a1c934c0c6bb863a66e34b2830807ee274f443323d7c74c10023bf078329abf2bc70d9125f481b388c3e9ac61a647c81a8f6ee6f7c156078a9dc4fc31adcb671
7
- data.tar.gz: 6a54d11609e88fadd4c4750966e66a1df705ab1a1c79b8c8c0b99f5d3a19467496c46db740dfb2937610c9c78e8a7bf14db7fe8112c7c8399bb43a16bdcd6937
6
+ metadata.gz: 0f7ca455177f14a4f0ad5dae5852a7b7441e7ef867e554678b8d55410d623cb7b1d0e4e4432dbd8f1c8cdc085943801d2b98601d9ea776cd1e93f6482a02e3b9
7
+ data.tar.gz: d4160ba11c06d24fc185c2b4cde86fc36c519499f383c79053246114b2e69d0e5be4504e87ea476f462e1439383f8c1a07a5efcab2701449cbd6458939faa13b
@@ -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'
@@ -11,7 +11,7 @@ Gem::Specification.new do |spec|
11
11
  # DESCRIPTION
12
12
  # spec.homepage = 'TODO'
13
13
  spec.license = 'Apache-2.0'
14
- spec.add_dependency 'inferno_core', '~> <%= Inferno::VERSION %>'
14
+ spec.add_dependency 'inferno_core', '~> <%= Inferno::VERSION.split('.').first(2).join('.') %>', '>= <%= Inferno::VERSION %>'
15
15
  spec.required_ruby_version = Gem::Requirement.new('>= 3.3.6')
16
16
  spec.metadata['inferno_test_kit'] = 'true'
17
17
  # spec.metadata['homepage_uri'] = spec.homepage
@@ -1,4 +1,4 @@
1
- /data/*.db
1
+ /data/*.db*
2
2
  /data/igs/*.tgz
3
3
  /data/redis/**/*.rdb
4
4
  /data/redis/**/*.aof
@@ -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
@@ -126,18 +126,10 @@
126
126
  "message": "Patient/85: Patient.extension[0]: Unknown extension http://hl7.org/fhir/us/core/StructureDefinition/us-core-race",
127
127
  "type": "info"
128
128
  },
129
- {
130
- "message": "Patient/85: Patient.extension[0].extension[0].value.ofType(Coding): The definition for the Code System with URI 'urn:oid:2.16.840.1.113883.6.238' doesn't provide any codes so the code cannot be validated",
131
- "type": "info"
132
- },
133
129
  {
134
130
  "message": "Patient/85: Patient.extension[1]: Unknown extension http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity",
135
131
  "type": "info"
136
132
  },
137
- {
138
- "message": "Patient/85: Patient.extension[1].extension[0].value.ofType(Coding): The definition for the Code System with URI 'urn:oid:2.16.840.1.113883.6.238' doesn't provide any codes so the code cannot be validated",
139
- "type": "info"
140
- },
141
133
  {
142
134
  "message": "Patient/85: Patient.extension[2]: Unknown extension http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex",
143
135
  "type": "info"
@@ -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)
@@ -46,7 +46,7 @@ RSpec.describe <%= module_name %>::PatientGroup do
46
46
  result = run(test, url: url, patient_id: patient_id)
47
47
 
48
48
  expect(result.result).to eq('fail'), result.result_message
49
- expect(result.result_message).to match(/200/)
49
+ expect(result.result_message).to include('200')
50
50
  end
51
51
 
52
52
  it 'fails if a Patient is not received' do
@@ -57,7 +57,7 @@ RSpec.describe <%= module_name %>::PatientGroup do
57
57
  result = run(test, url: url, patient_id: patient_id)
58
58
 
59
59
  expect(result.result).to eq('fail'), result.result_message
60
- expect(result.result_message).to match(/Patient/)
60
+ expect(result.result_message).to include('Patient')
61
61
  end
62
62
 
63
63
  it 'fails if the id received does not match the one requested' do
@@ -68,7 +68,7 @@ RSpec.describe <%= module_name %>::PatientGroup do
68
68
  result = run(test, url: url, patient_id: patient_id)
69
69
 
70
70
  expect(result.result).to eq('fail'), result.result_message
71
- expect(result.result_message).to match(/resource with id/)
71
+ expect(result.result_message).to include('resource with id')
72
72
  end
73
73
  end
74
74
 
@@ -1,3 +1,36 @@
1
+ module Inferno
2
+ module Config
3
+ module Boot
4
+ # Extracted from the :suites provider below so the enable_when
5
+ # validation can be unit tested against plain Test/TestGroup/TestSuite
6
+ # classes, instead of requiring a full application boot (which only
7
+ # happens once per process) just to exercise the raise.
8
+ module Suites
9
+ module_function
10
+
11
+ # A circular or undefined enable_when dependency means the input
12
+ # involved can never be enabled, so this is treated as fatal at load
13
+ # time rather than left for developers to discover once the suite is
14
+ # already running. `enable_when` is uninheritable, so a problem local
15
+ # to a group or test can be hidden from the suite's own merged view if
16
+ # a parent re-declares the same input name without repeating
17
+ # `enable_when` -- checking every runnable in the tree, not just the
18
+ # suite, is what catches that case.
19
+ def check_enable_when_errors!(descendant)
20
+ [*descendant.all_descendants.reverse, descendant].each do |runnable|
21
+ enable_when_errors = runnable.enable_when_problem_messages
22
+ next if enable_when_errors.empty?
23
+
24
+ raise StandardError,
25
+ "Error initializing test suite #{descendant.name} (id: #{descendant.id}) " \
26
+ "in '#{runnable.title || runnable.id}' (id: #{runnable.id}):\n- #{enable_when_errors.join("\n- ")}"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+
1
34
  Inferno::Application.register_provider(:suites) do
2
35
  prepare do
3
36
  target_container.start :logging
@@ -46,6 +79,8 @@ Inferno::Application.register_provider(:suites) do
46
79
  raise StandardError, "Error initializing test suite #{descendant.name}: test suite ID is not set"
47
80
  end
48
81
 
82
+ Inferno::Config::Boot::Suites.check_enable_when_errors!(descendant)
83
+
49
84
  # This will lock the short IDs if a short ID map for this suite is present
50
85
  descendant.assign_short_ids
51
86
  end
@@ -55,6 +55,19 @@ module Inferno
55
55
  @validator_session_repo ||= Inferno::Repositories::ValidatorSessions.new
56
56
  end
57
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
+
58
71
  # Set the url of the validator service
59
72
  #
60
73
  # @param validator_url [String]
@@ -212,6 +225,36 @@ module Inferno
212
225
  }
213
226
  end
214
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
+
215
258
  # @private
216
259
  # Used internally by perform_additional_validation
217
260
  def additional_validations
@@ -334,6 +377,7 @@ module Inferno
334
377
 
335
378
  # 4. Mark resources as filtered
336
379
  mark_issues_for_filtering(issues)
380
+ log_validation_result(profile_url, issues, runnable) if debug_logging_enabled?
337
381
 
338
382
  # 5. Add error messages to runnable
339
383
  filtered_issues = issues.reject(&:filtered)
@@ -447,12 +491,57 @@ module Inferno
447
491
  # @private
448
492
  def call_validator(target, profile_url)
449
493
  request_body = wrap_target_for_hl7_wrapper(target, profile_url)
494
+
450
495
  Faraday.new(
451
496
  url,
452
497
  request: { timeout: 600 }
453
498
  ).post('validate', request_body, content_type: 'application/json')
454
499
  end
455
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
+
456
545
  # @private
457
546
  # Post an object to the validation service for validating.
458
547
  # Returns the raw validator response body.
@@ -703,10 +792,7 @@ module Inferno
703
792
  end
704
793
 
705
794
  wrapped_resource = {
706
- context_key => {
707
- **validation_context.definition,
708
- profiles: [profile_url]
709
- },
795
+ context_key => build_validation_context(profile_url),
710
796
  filesToValidate: [
711
797
  {
712
798
  fileName: "#{profile_url.split('/').last}.json",
@@ -16,7 +16,8 @@ module Inferno
16
16
  # @option input_params [Hash] :options Possible input option formats based on input type
17
17
  # @option options [Array] :list_options Array of options for input formats
18
18
  # that require a list of possible values (radio and checkbox)
19
- # @option input_params [Hash] :enable_when Conditions for showing the input. Must be a Hash
19
+ # @option input_params [Hash] :enable_when Conditions for showing the input and determining
20
+ # whether it is required. Must be a Hash
20
21
  # with String :input_name (the name of the controlling input) and String :value (the value
21
22
  # that triggers visibility). For checkbox inputs the value must be a JSON-encoded sorted
22
23
  # array, e.g. '["a","b"]'.
@@ -97,9 +98,12 @@ module Inferno
97
98
  end
98
99
 
99
100
  # @private
100
- def required_inputs(selected_suite_options)
101
- available_inputs(selected_suite_options)
102
- .reject { |_, input| input.optional }
101
+ def required_inputs(selected_suite_options, submitted_inputs = nil)
102
+ input_values = (submitted_inputs || []).to_h { |input| [input[:name].to_s, input[:value]] }
103
+
104
+ available = available_inputs(selected_suite_options)
105
+ available
106
+ .reject { |_, input| input.optional || !input.enabled?(input_values, available) }
103
107
  .map { |_, input| input.name }
104
108
  end
105
109
 
@@ -107,7 +111,10 @@ module Inferno
107
111
  def missing_inputs(submitted_inputs, selected_suite_options)
108
112
  submitted_inputs = [] if submitted_inputs.nil?
109
113
 
110
- required_inputs(selected_suite_options).map(&:to_s) - submitted_inputs.map { |input| input[:name] }
114
+ required_input_names = required_inputs(selected_suite_options, submitted_inputs).map(&:to_s)
115
+ submitted_input_names = submitted_inputs.map { |input| input[:name] }
116
+
117
+ required_input_names - submitted_input_names
111
118
  end
112
119
 
113
120
  # Define a particular order for inputs to be presented in the API/UI
@@ -204,6 +211,95 @@ module Inferno
204
211
 
205
212
  order_available_inputs(child_inputs.merge(available_inputs))
206
213
  end
214
+
215
+ # @private
216
+ # Automatically detect, among this runnable's own inputs: circular
217
+ # `enable_when` dependencies (e.g. input `a` is enabled_when `b`, which
218
+ # is itself enabled_when `a`), and `enable_when` conditions that
219
+ # reference an input not declared on this same runnable (e.g. a typo'd
220
+ # `input_name`). Both silently evaluate as permanently disabled at
221
+ # runtime with no other diagnostic (see `Entities::Input#enabled?`'s
222
+ # `visited` guard and its `input_values.key?` check), so they're
223
+ # treated as fatal at load time instead. Note `enable_when` is an
224
+ # uninheritable attribute -- it only has meaning, and can only
225
+ # reference another input, at the level it's declared -- so a problem
226
+ # local to a group or test can be masked if a parent re-declares the
227
+ # same input name without repeating `enable_when`; checking every
228
+ # runnable in the tree (not just the suite) is what catches those.
229
+ def enable_when_problem_messages
230
+ inputs_by_name = enable_when_local_inputs
231
+ edges = enable_when_edges(inputs_by_name)
232
+
233
+ enable_when_undefined_reference_messages(edges, inputs_by_name) + enable_when_calculated_cycle_messages(edges)
234
+ end
235
+
236
+ # @private
237
+ # Flags any `enable_when.input_name` that doesn't name an input
238
+ # declared on this same runnable.
239
+ def enable_when_undefined_reference_messages(edges, inputs_by_name)
240
+ local_names = inputs_by_name.values.map(&:name)
241
+
242
+ edges.filter_map do |name, referenced_name|
243
+ next if local_names.include?(referenced_name)
244
+
245
+ "Input '#{name}' has an enable_when condition that references '#{referenced_name}', " \
246
+ 'which is not an input defined on the same test/group/suite.'
247
+ end
248
+ end
249
+
250
+ # @private
251
+ def enable_when_calculated_cycle_messages(edges)
252
+ globally_visited = {}
253
+
254
+ edges.each_key.filter_map do |start_name|
255
+ next if globally_visited[start_name]
256
+
257
+ cycle = follow_enable_when_chain(start_name, edges, globally_visited)
258
+ next unless cycle
259
+
260
+ chain = (cycle + [cycle.first]).join(' -> ')
261
+ "Circular enable_when dependency detected in input '#{cycle.first}': #{chain}. "
262
+ end
263
+ end
264
+
265
+ # @private
266
+ # Maps each input's name to the name of the input it depends on via
267
+ # `enable_when`, for every input in this runnable that has one.
268
+ # `enable_when` is uninheritable (see `Entities::Input::UNINHERITABLE_ATTRIBUTES`),
269
+ # so it's unaffected by the child merge in `available_inputs` -- reading
270
+ # straight from this runnable's own local input config avoids that
271
+ # merge's subtree walk entirely.
272
+ def enable_when_edges(inputs_by_name = enable_when_local_inputs)
273
+ inputs_by_name.each_with_object({}) do |(_, input), depends_on|
274
+ depends_on[input.name] = input.enable_when[:input_name] if input.enable_when.present?
275
+ end
276
+ end
277
+
278
+ # @private
279
+ def enable_when_local_inputs
280
+ config.inputs.slice(*inputs)
281
+ end
282
+
283
+ # @private
284
+ # Follows the `enable_when` chain starting at `start_name`, marking
285
+ # every node visited along the way. Returns the cycle (as an array of
286
+ # input names) if one is found, otherwise nil.
287
+ def follow_enable_when_chain(start_name, depends_on, globally_visited)
288
+ path = []
289
+ node = start_name
290
+
291
+ until node.nil? || globally_visited[node]
292
+ break if path.index(node)
293
+
294
+ path << node
295
+ node = depends_on[node]
296
+ end
297
+
298
+ path.each { |name| globally_visited[name] = true }
299
+
300
+ cycle_start_index = path.index(node)
301
+ cycle_start_index && path[cycle_start_index..]
302
+ end
207
303
  end
208
304
  end
209
305
  end