inferno_core 1.4.2 → 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 +8 -4
- data/lib/inferno/apps/cli/requirements_coverage_checker.rb +1 -1
- data/lib/inferno/dsl/fhir_resource_navigation.rb +12 -2
- data/lib/inferno/dsl/fhir_resource_validation.rb +96 -0
- 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 +4 -1
- data/lib/inferno/dsl/suite_endpoint.rb +164 -6
- data/lib/inferno/dsl.rb +1 -0
- data/lib/inferno/entities/ig.rb +51 -8
- data/lib/inferno/public/bundle.js +34 -34
- data/lib/inferno/public/bundle.js.LICENSE.txt +0 -2
- data/lib/inferno/test_runner.rb +55 -40
- data/lib/inferno/version.rb +1 -1
- metadata +3 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require 'hanami/controller'
|
|
2
2
|
require 'rack/request'
|
|
3
|
+
require 'fhir_models'
|
|
3
4
|
require_relative '../ext/rack'
|
|
4
5
|
|
|
5
6
|
module Inferno
|
|
@@ -15,6 +16,8 @@ module Inferno
|
|
|
15
16
|
# request.headers['authorization']&.delete_prefix('Bearer ')
|
|
16
17
|
# end
|
|
17
18
|
#
|
|
19
|
+
# error_response_format :operation_outcome
|
|
20
|
+
#
|
|
18
21
|
# # Return a json FHIR Patient resource
|
|
19
22
|
# def make_response
|
|
20
23
|
# response.status = 200
|
|
@@ -57,9 +60,47 @@ module Inferno
|
|
|
57
60
|
# end
|
|
58
61
|
# end
|
|
59
62
|
# end
|
|
63
|
+
#
|
|
60
64
|
class SuiteEndpoint < Hanami::Action
|
|
61
65
|
attr_reader :req, :res
|
|
62
66
|
|
|
67
|
+
# The built-in options for `error_response_format`
|
|
68
|
+
ERROR_RESPONSE_FORMATS = [:text, :operation_outcome].freeze
|
|
69
|
+
|
|
70
|
+
class << self
|
|
71
|
+
# Select one of Inferno's standard response formats to be returned
|
|
72
|
+
# whenever Inferno has to render an error response of its own due
|
|
73
|
+
# to problems finding the target session or an unhandled exception.
|
|
74
|
+
# You can override {#no_session_response} to customize the response
|
|
75
|
+
# in the no-session case.
|
|
76
|
+
#
|
|
77
|
+
# * `:text` (default): a `500` response with a plain text message
|
|
78
|
+
# * `:operation_outcome`: a `500` response with a FHIR
|
|
79
|
+
# `OperationOutcome` serialized as `application/fhir+json`
|
|
80
|
+
#
|
|
81
|
+
# @param format [Symbol] `:text` or `:operation_outcome`
|
|
82
|
+
# @return [void]
|
|
83
|
+
#
|
|
84
|
+
# @example
|
|
85
|
+
# class MyEndpoint < Inferno::DSL::SuiteEndpoint
|
|
86
|
+
# error_response_format :operation_outcome
|
|
87
|
+
# end
|
|
88
|
+
def error_response_format(format)
|
|
89
|
+
unless ERROR_RESPONSE_FORMATS.include?(format)
|
|
90
|
+
raise ArgumentError,
|
|
91
|
+
"Unknown error_response_format `#{format.inspect}`. " \
|
|
92
|
+
"Must be one of #{ERROR_RESPONSE_FORMATS.join(', ')}."
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
@error_response_format_value = format
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @private
|
|
99
|
+
def error_response_format_value
|
|
100
|
+
@error_response_format_value ||= :text
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
63
104
|
# @!group Overrides These methods should be overridden by subclasses to
|
|
64
105
|
# define the behavior of the endpoint
|
|
65
106
|
|
|
@@ -78,6 +119,22 @@ module Inferno
|
|
|
78
119
|
nil
|
|
79
120
|
end
|
|
80
121
|
|
|
122
|
+
# Override this method to provide a short narrative description of
|
|
123
|
+
# where the test run identifier is expected to be found in an
|
|
124
|
+
# incoming request. When provided, this description is appended to
|
|
125
|
+
# the {#no_session_message} to help implementers debug requests that
|
|
126
|
+
# don't match a waiting test run.
|
|
127
|
+
#
|
|
128
|
+
# @return [String]
|
|
129
|
+
#
|
|
130
|
+
# @example
|
|
131
|
+
# def test_run_identifier_location_description
|
|
132
|
+
# "the 'code' query parameter"
|
|
133
|
+
# end
|
|
134
|
+
def test_run_identifier_location_description
|
|
135
|
+
''
|
|
136
|
+
end
|
|
137
|
+
|
|
81
138
|
# Override this method to build the response.
|
|
82
139
|
#
|
|
83
140
|
# @return [Void]
|
|
@@ -128,6 +185,26 @@ module Inferno
|
|
|
128
185
|
true
|
|
129
186
|
end
|
|
130
187
|
|
|
188
|
+
# Override this method to fully customize the response returned when no
|
|
189
|
+
# waiting test run/session can be found for the incoming request. Set
|
|
190
|
+
# `response.status` and `response.body` (and `response.content_type`, if
|
|
191
|
+
# needed) — Inferno halts the request with those values. By default,
|
|
192
|
+
# this renders one of Inferno's standard responses based on the format
|
|
193
|
+
# selected with `error_response_format` (a plain text `500` response if
|
|
194
|
+
# none was selected).
|
|
195
|
+
#
|
|
196
|
+
# @return [Void]
|
|
197
|
+
#
|
|
198
|
+
# @example
|
|
199
|
+
# def no_session_response
|
|
200
|
+
# response.status = 404
|
|
201
|
+
# response.format = :json
|
|
202
|
+
# response.body = { error: 'no matching session' }.to_json
|
|
203
|
+
# end
|
|
204
|
+
def no_session_response
|
|
205
|
+
error_response(no_session_message, code: 'not-found')
|
|
206
|
+
end
|
|
207
|
+
|
|
131
208
|
# @!endgroup
|
|
132
209
|
|
|
133
210
|
# @private
|
|
@@ -192,7 +269,7 @@ module Inferno
|
|
|
192
269
|
def test_run
|
|
193
270
|
@test_run ||=
|
|
194
271
|
test_runs_repo.find_latest_waiting_by_identifier(find_test_run_identifier).tap do |test_run|
|
|
195
|
-
|
|
272
|
+
render_error_and_halt { no_session_response } if test_run.nil?
|
|
196
273
|
end
|
|
197
274
|
end
|
|
198
275
|
|
|
@@ -216,11 +293,86 @@ module Inferno
|
|
|
216
293
|
@logger ||= Application['logger']
|
|
217
294
|
end
|
|
218
295
|
|
|
296
|
+
# @private
|
|
297
|
+
def log_error(error, url: request.url)
|
|
298
|
+
session_prefix = @test_run ? " session=#{@test_run.test_session_id}" : ''
|
|
299
|
+
logger.error("[#{url}]#{session_prefix} #{error.full_message}")
|
|
300
|
+
end
|
|
301
|
+
|
|
219
302
|
# @private
|
|
220
303
|
def find_test_run_identifier
|
|
221
|
-
@test_run_identifier
|
|
304
|
+
return @test_run_identifier if defined?(@test_run_identifier) # handle memoization in the nil case
|
|
305
|
+
|
|
306
|
+
@test_run_identifier = test_run_identifier
|
|
222
307
|
rescue StandardError => e
|
|
223
|
-
|
|
308
|
+
log_error(e)
|
|
309
|
+
render_error_and_halt do
|
|
310
|
+
error_response(
|
|
311
|
+
'An error occurred while determining the test run identifier for this request.',
|
|
312
|
+
code: 'exception',
|
|
313
|
+
diagnostics: e.full_message
|
|
314
|
+
)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# @private
|
|
319
|
+
def no_session_message
|
|
320
|
+
base_message = "Unable to find test run for request to '#{request.url}'"
|
|
321
|
+
location = test_run_identifier_location_description
|
|
322
|
+
identifier = find_test_run_identifier
|
|
323
|
+
|
|
324
|
+
if identifier.blank?
|
|
325
|
+
detail = location.present? ? " in #{location}" : ''
|
|
326
|
+
"#{base_message}: no identifier found#{detail}."
|
|
327
|
+
else
|
|
328
|
+
detail = location.present? ? ", found in #{location}," : ''
|
|
329
|
+
"#{base_message}: identifier '#{identifier}'#{detail} is not associated with a waiting session."
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# @private
|
|
334
|
+
# Yields to build the response, then halts with whatever ended up in
|
|
335
|
+
# `response.status`/`response.body`. Centralizing the halt here means
|
|
336
|
+
# overrides of the response-building hooks (e.g. #no_session_response)
|
|
337
|
+
# never need to remember to call `halt` themselves.
|
|
338
|
+
def render_error_and_halt
|
|
339
|
+
yield
|
|
340
|
+
halt response.status, response.body.join
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# @private
|
|
344
|
+
# `message` is a short, human-readable summary (goes in the
|
|
345
|
+
# OperationOutcome issue's `details.text`, or stands alone as the whole
|
|
346
|
+
# plain text body). `diagnostics`, if given, is technical detail — e.g.
|
|
347
|
+
# an exception's full backtrace — that goes in the issue's
|
|
348
|
+
# `diagnostics` element, or is appended to the plain text body.
|
|
349
|
+
def error_response(message, code:, diagnostics: nil)
|
|
350
|
+
case self.class.error_response_format_value
|
|
351
|
+
when :operation_outcome
|
|
352
|
+
operation_outcome_error_response(message, code:, diagnostics:)
|
|
353
|
+
else
|
|
354
|
+
text_error_response(message, diagnostics:)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# @private
|
|
359
|
+
def text_error_response(message, diagnostics: nil)
|
|
360
|
+
response.status = 500
|
|
361
|
+
response.body = diagnostics ? "#{message}\n#{diagnostics}" : message
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# @private
|
|
365
|
+
def operation_outcome_error_response(message, code:, diagnostics: nil)
|
|
366
|
+
issue = FHIR::OperationOutcome::Issue.new(
|
|
367
|
+
severity: 'fatal',
|
|
368
|
+
code:,
|
|
369
|
+
details: FHIR::CodeableConcept.new(text: message)
|
|
370
|
+
)
|
|
371
|
+
issue.diagnostics = diagnostics if diagnostics
|
|
372
|
+
|
|
373
|
+
response.status = 500
|
|
374
|
+
response.content_type = 'application/fhir+json'
|
|
375
|
+
response.body = FHIR::OperationOutcome.new(issue: [issue]).to_json
|
|
224
376
|
end
|
|
225
377
|
|
|
226
378
|
# @private
|
|
@@ -272,12 +424,18 @@ module Inferno
|
|
|
272
424
|
|
|
273
425
|
make_response
|
|
274
426
|
rescue StandardError => e
|
|
275
|
-
|
|
427
|
+
log_error(e)
|
|
428
|
+
render_error_and_halt do
|
|
429
|
+
error_response(
|
|
430
|
+
'An error occurred while processing this request.',
|
|
431
|
+
code: 'exception',
|
|
432
|
+
diagnostics: e.full_message
|
|
433
|
+
)
|
|
434
|
+
end
|
|
276
435
|
end
|
|
277
436
|
|
|
278
437
|
# @private
|
|
279
438
|
def add_persistence_callback # rubocop:disable Metrics/CyclomaticComplexity
|
|
280
|
-
logger = Application['logger']
|
|
281
439
|
env = req.env
|
|
282
440
|
env['rack.after_reply'] ||= []
|
|
283
441
|
env['rack.after_reply'] << proc do
|
|
@@ -332,7 +490,7 @@ module Inferno
|
|
|
332
490
|
)
|
|
333
491
|
end
|
|
334
492
|
rescue StandardError => e
|
|
335
|
-
|
|
493
|
+
log_error(e, url:)
|
|
336
494
|
end
|
|
337
495
|
end
|
|
338
496
|
end
|
data/lib/inferno/dsl.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative 'dsl/fhir_resource_navigation'
|
|
|
7
7
|
require_relative 'dsl/fhir_resource_validation'
|
|
8
8
|
require_relative 'dsl/fhirpath_evaluation'
|
|
9
9
|
require_relative 'dsl/http_client'
|
|
10
|
+
require_relative 'dsl/profile_metadata'
|
|
10
11
|
require_relative 'dsl/must_support_assessment'
|
|
11
12
|
require_relative 'dsl/results'
|
|
12
13
|
require_relative 'dsl/runnable'
|
data/lib/inferno/entities/ig.rb
CHANGED
|
@@ -27,7 +27,14 @@ module Inferno
|
|
|
27
27
|
@examples = []
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
# @param ig_path [String] path to either a `.tgz` IG package or a directory containing an
|
|
31
|
+
# unpacked one
|
|
32
|
+
# @param standalone_resources_directory [String] optional path to a directory of loose
|
|
33
|
+
# `*.json` FHIR resource files to merge in alongside the IG, eg author-maintained
|
|
34
|
+
# resources (extra examples under 'package/example', local overrides) that aren't
|
|
35
|
+
# part of the IG package itself. Primarily useful for test kit generator scripts.
|
|
36
|
+
# @return [IG]
|
|
37
|
+
def self.from_file(ig_path, standalone_resources_directory: nil)
|
|
31
38
|
raise "#{ig_path} does not exist" unless File.exist?(ig_path)
|
|
32
39
|
|
|
33
40
|
# fhir_models by default logs the entire content of non-FHIR files
|
|
@@ -35,13 +42,18 @@ module Inferno
|
|
|
35
42
|
original_logger = FHIR.logger
|
|
36
43
|
FHIR.logger = Logger.new('/dev/null')
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
ig =
|
|
46
|
+
if File.directory?(ig_path)
|
|
47
|
+
from_directory(ig_path)
|
|
48
|
+
elsif ig_path.end_with? '.tgz'
|
|
49
|
+
from_tgz(ig_path)
|
|
50
|
+
else
|
|
51
|
+
raise "Unable to load #{ig_path} as it does not appear to be a directory or a .tgz file"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
ig.merge_standalone_resources(standalone_resources_directory) if standalone_resources_directory
|
|
55
|
+
|
|
56
|
+
ig
|
|
45
57
|
ensure
|
|
46
58
|
FHIR.logger = original_logger if defined? original_logger
|
|
47
59
|
end
|
|
@@ -122,6 +134,37 @@ module Inferno
|
|
|
122
134
|
end
|
|
123
135
|
end
|
|
124
136
|
|
|
137
|
+
# Merge in loose FHIR resource files from a directory that isn't part of the IG package
|
|
138
|
+
# itself, eg author-maintained resources (extra examples, local SearchParameter overrides,
|
|
139
|
+
# etc) that a test kit generator keeps alongside a downloaded package. Files that aren't
|
|
140
|
+
# valid FHIR resources are skipped. Does nothing if the directory doesn't exist.
|
|
141
|
+
#
|
|
142
|
+
# Files are classified the same way as within an IG package: those under a `package/example`
|
|
143
|
+
# subdirectory are added to {#examples}, everything else (including files directly in
|
|
144
|
+
# `directory`) is added to {#resources_by_type}.
|
|
145
|
+
# @param directory [String] path to a directory of loose `*.json` FHIR resource files,
|
|
146
|
+
# optionally nested under a `package/example` subdirectory
|
|
147
|
+
# @return [self]
|
|
148
|
+
def merge_standalone_resources(directory)
|
|
149
|
+
return self unless File.directory?(directory)
|
|
150
|
+
|
|
151
|
+
base_path = Pathname.new(directory)
|
|
152
|
+
Dir.glob(File.join(directory, '**', '*.json')).each do |file_path|
|
|
153
|
+
relative_path = Pathname.new(file_path).relative_path_from(base_path).to_s
|
|
154
|
+
|
|
155
|
+
begin
|
|
156
|
+
resource = FHIR::Json.from_json(File.read(file_path))
|
|
157
|
+
next if resource.nil?
|
|
158
|
+
rescue StandardError
|
|
159
|
+
next
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
handle_resource(resource, relative_path)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
self
|
|
166
|
+
end
|
|
167
|
+
|
|
125
168
|
def self.extract_package_id(ig_resource)
|
|
126
169
|
"#{ig_resource.id}##{ig_resource.version || 'current'}"
|
|
127
170
|
end
|