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.
@@ -0,0 +1,113 @@
1
+ module Inferno
2
+ module DSL
3
+ # A ProfileMetadata object is a small, inheritable data holder for generated metadata about a
4
+ # profile or a group of tests -- most notably the Must Support metadata consumed by
5
+ # {MustSupportAssessment#missing_must_support_elements} and
6
+ # {Assertions#assert_must_support_elements_present} via their `metadata:` argument, alongside
7
+ # whatever other generated metadata a test kit needs to carry around (searches, bindings,
8
+ # references, etc).
9
+ #
10
+ # It exists so that test kits don't need to reach for `OpenStruct` (which accepts any attribute
11
+ # silently, making typos and generator/consumer drift hard to catch) or hand-roll their own
12
+ # version of this exact pattern.
13
+ #
14
+ # Test kits define a subclass and declare whatever attributes they need with `.attribute`.
15
+ # `must_supports` is always available, since it's the attribute Inferno's Must Support logic
16
+ # reads:
17
+ #
18
+ # class GroupMetadata < Inferno::DSL::ProfileMetadata
19
+ # attribute :searches
20
+ # attribute :bindings
21
+ # end
22
+ #
23
+ # metadata = GroupMetadata.from_file('path/to/generated/metadata.yml')
24
+ # metadata.must_supports #=> { elements: [...], extensions: [...], slices: [...] }
25
+ #
26
+ # @see MustSupportAssessment#missing_must_support_elements
27
+ class ProfileMetadata
28
+ class << self
29
+ # The full list of attribute names declared on this class and its ancestors.
30
+ # @return [Array<Symbol>]
31
+ def attribute_names
32
+ @attribute_names ||= superclass.respond_to?(:attribute_names) ? superclass.attribute_names.dup : []
33
+ end
34
+
35
+ # Declare an attribute that instances of this class (and subclasses) may be initialized with.
36
+ # @param name [Symbol]
37
+ # @return [void]
38
+ def attribute(name)
39
+ name = name.to_sym
40
+ attribute_names << name unless attribute_names.include?(name)
41
+ attr_accessor name
42
+ end
43
+
44
+ # Build an instance from a YAML file, eg one written by a metadata generator.
45
+ # @param path [String, Pathname]
46
+ # @return [ProfileMetadata]
47
+ def from_file(path)
48
+ new(YAML.load_file(path, aliases: true))
49
+ end
50
+ end
51
+
52
+ # Profile identity, mirroring fields read directly off the FHIR StructureDefinition
53
+ # (`resource` is the resource type the profile constrains, ie `profile.type`).
54
+ attribute :resource
55
+ attribute :profile_url
56
+ attribute :profile_name
57
+ attribute :profile_version
58
+
59
+ attribute :must_supports
60
+
61
+ # @param metadata [Hash] a hash of attribute name/value pairs. Keys not declared with
62
+ # `.attribute` raise an error, so that a mismatch between a metadata generator and this
63
+ # class's declared attributes is caught immediately rather than silently ignored.
64
+ def initialize(metadata = {})
65
+ metadata.each do |key, value|
66
+ key = key.to_sym
67
+ raise "Unknown #{self.class} attribute: #{key}" unless self.class.attribute_names.include?(key)
68
+
69
+ public_send(:"#{key}=", value)
70
+ end
71
+
72
+ self.must_supports ||= {}
73
+ end
74
+
75
+ # @return [Hash] the declared attributes and their current values, omitting unset (nil) ones
76
+ def to_hash
77
+ self.class.attribute_names.each_with_object({}) do |name, hash|
78
+ value = public_send(name)
79
+ hash[name] = value unless value.nil?
80
+ end
81
+ end
82
+
83
+ # Every Must Support element, slice, and extension in `must_supports`, each represented as a
84
+ # single string and sorted alphabetically. Unlike the list returned by a missing-elements
85
+ # check, this includes everything that's expected to be supported, not just what's absent
86
+ # from a particular set of resources.
87
+ #
88
+ # Elements are represented as their `path`, with the matched `fixed_value` appended after a
89
+ # colon where present (eg `"code.coding.code:45473-6"`). Slices and extensions are
90
+ # represented as their `path`, with their `slice_name` appended after a colon where present
91
+ # (eg `"category:us-core"`, `"extension:us-core-race"`).
92
+ # @return [Array<String>]
93
+ def must_support_strings
94
+ element_strings =
95
+ Array.wrap(must_supports[:elements]).map do |element|
96
+ must_support_string(element[:path], element[:fixed_value])
97
+ end
98
+ slice_strings =
99
+ Array.wrap(must_supports[:slices]).map { |slice| must_support_string(slice[:path], slice[:slice_name]) }
100
+ extension_strings =
101
+ Array.wrap(must_supports[:extensions]).map { |ext| must_support_string(ext[:path], ext[:slice_name]) }
102
+
103
+ (element_strings + slice_strings + extension_strings).sort
104
+ end
105
+
106
+ private
107
+
108
+ def must_support_string(path, suffix)
109
+ suffix.present? ? "#{path}:#{suffix}" : path
110
+ end
111
+ end
112
+ end
113
+ end
@@ -84,7 +84,10 @@ module Inferno
84
84
 
85
85
  test_run = find_test_run(test_run_identifier)
86
86
 
87
- halt 500, "Unable to find test run with identifier '#{test_run_identifier}'." if test_run.nil?
87
+ if test_run.nil?
88
+ halt 500, "Unable to find test run with identifier '#{test_run_identifier}' " \
89
+ "on request to '#{request.url}'."
90
+ end
88
91
 
89
92
  test_runs_repo.mark_as_no_longer_waiting(test_run.id)
90
93
 
@@ -94,7 +97,16 @@ module Inferno
94
97
  update_result(waiting_result, result_message)
95
98
  persist_request(request, test_run, waiting_result, test)
96
99
 
97
- Jobs.perform(Jobs::ResumeTestRun, test_run.id)
100
+ Jobs.perform(
101
+ Jobs::ResumeTestRun,
102
+ test_run.id,
103
+ tags: [
104
+ 'source:route',
105
+ "session:#{test_run.test_session_id}",
106
+ "run:#{test_run.test_suite_id || test_run.test_group_id || test_run.test_id}",
107
+ "test:#{test.id}"
108
+ ]
109
+ )
98
110
 
99
111
  res.redirect_to redirect_route(test_run, test)
100
112
  end
@@ -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
@@ -254,6 +406,8 @@ module Inferno
254
406
  def resume
255
407
  req.env['inferno.resume_test_run'] = true
256
408
  req.env['inferno.test_run_id'] = test_run.id
409
+ req.env['inferno.run_identifier'] = test_run.test_suite_id || test_run.test_group_id || test_run.test_id
410
+ req.env['inferno.waiting_test_id'] = test.id
257
411
  end
258
412
 
259
413
  # @private
@@ -270,12 +424,18 @@ module Inferno
270
424
 
271
425
  make_response
272
426
  rescue StandardError => e
273
- 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
274
435
  end
275
436
 
276
437
  # @private
277
438
  def add_persistence_callback # rubocop:disable Metrics/CyclomaticComplexity
278
- logger = Application['logger']
279
439
  env = req.env
280
440
  env['rack.after_reply'] ||= []
281
441
  env['rack.after_reply'] << proc do
@@ -318,10 +478,19 @@ module Inferno
318
478
  test_run_id = env['inferno.test_run_id']
319
479
  Inferno::Repositories::TestRuns.new.mark_as_no_longer_waiting(test_run_id)
320
480
 
321
- Inferno::Jobs.perform(Jobs::ResumeTestRun, test_run_id)
481
+ Inferno::Jobs.perform(
482
+ Jobs::ResumeTestRun,
483
+ test_run_id,
484
+ tags: [
485
+ 'source:suite_endpoint',
486
+ "session:#{env['inferno.test_session_id']}",
487
+ "run:#{env['inferno.run_identifier']}",
488
+ "test:#{env['inferno.waiting_test_id']}"
489
+ ]
490
+ )
322
491
  end
323
492
  rescue StandardError => e
324
- logger.error(e.full_message)
493
+ log_error(e, url:)
325
494
  end
326
495
  end
327
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
data/lib/inferno/jobs.rb CHANGED
@@ -6,9 +6,13 @@ require_relative 'jobs/invoke_validator_session'
6
6
 
7
7
  module Inferno
8
8
  module Jobs
9
- def self.perform(job_klass, *params, force_synchronous: false)
9
+ def self.perform(job_klass, *params, force_synchronous: false, tags: nil)
10
+ tags = Array(tags).compact
11
+
10
12
  if force_synchronous || (Application['async_jobs'] == false)
11
13
  job_klass.new.perform(*params)
14
+ elsif tags.any?
15
+ job_klass.set(tags:).perform_async(*params)
12
16
  else
13
17
  job_klass.perform_async(*params)
14
18
  end