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.
@@ -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
- halt 500, "Unable to find test run with identifier '#{test_run_identifier}'." if test_run.nil?
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 ||= 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
- halt 500, "Unable to determine test run identifier:\n#{e.full_message}"
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
- halt 500, e.full_message
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
- logger.error(e.full_message)
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'
@@ -27,7 +27,14 @@ module Inferno
27
27
  @examples = []
28
28
  end
29
29
 
30
- def self.from_file(ig_path)
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
- if File.directory?(ig_path)
39
- from_directory(ig_path)
40
- elsif ig_path.end_with? '.tgz'
41
- from_tgz(ig_path)
42
- else
43
- raise "Unable to load #{ig_path} as it does not appear to be a directory or a .tgz file"
44
- end
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