pacio_inferno_core 0.1.0

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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +201 -0
  3. data/lib/pacio_inferno_core/custom_groups/capability_statement/conformance_support_test.rb +41 -0
  4. data/lib/pacio_inferno_core/custom_groups/capability_statement/fhir_version_test.rb +15 -0
  5. data/lib/pacio_inferno_core/custom_groups/capability_statement/instantiate_test.rb +23 -0
  6. data/lib/pacio_inferno_core/custom_groups/capability_statement/json_support_test.rb +40 -0
  7. data/lib/pacio_inferno_core/date_search_validation.rb +112 -0
  8. data/lib/pacio_inferno_core/fhir_resource_navigation.rb +7 -0
  9. data/lib/pacio_inferno_core/generator/group_generator.rb +161 -0
  10. data/lib/pacio_inferno_core/generator/group_metadata.rb +114 -0
  11. data/lib/pacio_inferno_core/generator/group_metadata_extractor.rb +301 -0
  12. data/lib/pacio_inferno_core/generator/ig_loader.rb +118 -0
  13. data/lib/pacio_inferno_core/generator/ig_metadata.rb +60 -0
  14. data/lib/pacio_inferno_core/generator/ig_metadata_extractor.rb +88 -0
  15. data/lib/pacio_inferno_core/generator/ig_resources.rb +88 -0
  16. data/lib/pacio_inferno_core/generator/must_support_metadata_extractor.rb +8 -0
  17. data/lib/pacio_inferno_core/generator/must_support_test_generator.rb +138 -0
  18. data/lib/pacio_inferno_core/generator/naming.rb +50 -0
  19. data/lib/pacio_inferno_core/generator/read_test_generator.rb +102 -0
  20. data/lib/pacio_inferno_core/generator/reference_resolution_test_generator.rb +96 -0
  21. data/lib/pacio_inferno_core/generator/search_definition_metadata_extractor.rb +228 -0
  22. data/lib/pacio_inferno_core/generator/search_metadata_extractor.rb +78 -0
  23. data/lib/pacio_inferno_core/generator/search_test_generator.rb +298 -0
  24. data/lib/pacio_inferno_core/generator/special_cases.rb +61 -0
  25. data/lib/pacio_inferno_core/generator/suite_generator.rb +105 -0
  26. data/lib/pacio_inferno_core/generator/templates/group.rb.erb +27 -0
  27. data/lib/pacio_inferno_core/generator/templates/must_support.rb.erb +36 -0
  28. data/lib/pacio_inferno_core/generator/templates/read.rb.erb +34 -0
  29. data/lib/pacio_inferno_core/generator/templates/reference_resolution.rb.erb +40 -0
  30. data/lib/pacio_inferno_core/generator/templates/search.rb.erb +40 -0
  31. data/lib/pacio_inferno_core/generator/templates/validation.rb.erb +36 -0
  32. data/lib/pacio_inferno_core/generator/terminology_binding_metadata_extractor.rb +116 -0
  33. data/lib/pacio_inferno_core/generator/validation_test_generator.rb +146 -0
  34. data/lib/pacio_inferno_core/generator/value_extractor.rb +152 -0
  35. data/lib/pacio_inferno_core/generator.rb +130 -0
  36. data/lib/pacio_inferno_core/must_support_test.rb +20 -0
  37. data/lib/pacio_inferno_core/primitive_type.rb +5 -0
  38. data/lib/pacio_inferno_core/read_test.rb +103 -0
  39. data/lib/pacio_inferno_core/reference_resolution_test.rb +181 -0
  40. data/lib/pacio_inferno_core/request_logger.rb +46 -0
  41. data/lib/pacio_inferno_core/resource_search_param_checker.rb +136 -0
  42. data/lib/pacio_inferno_core/search_test.rb +859 -0
  43. data/lib/pacio_inferno_core/search_test_properties.rb +56 -0
  44. data/lib/pacio_inferno_core/validation_test.rb +55 -0
  45. data/lib/pacio_inferno_core/version.rb +4 -0
  46. data/lib/pacio_inferno_core/well_known_code_systems.rb +21 -0
  47. metadata +165 -0
@@ -0,0 +1,859 @@
1
+ require_relative 'date_search_validation'
2
+ require_relative 'resource_search_param_checker'
3
+ require_relative 'search_test_properties'
4
+ require_relative 'well_known_code_systems'
5
+ require_relative 'fhir_resource_navigation'
6
+
7
+ module PacioInfernoCore
8
+ module SearchTest
9
+ extend Forwardable
10
+ include DateSearchValidation
11
+ include Inferno::DSL::FHIRResourceNavigation
12
+ include ResourceSearchParamChecker
13
+ include WellKnownCodeSystems
14
+
15
+ def_delegators 'self.class', :metadata, :provenance_metadata, :properties
16
+ def_delegators 'properties',
17
+ :resource_type,
18
+ :search_param_names,
19
+ :saves_delayed_references?,
20
+ :first_search?,
21
+ :fixed_value_search?,
22
+ :possible_status_search?,
23
+ :test_medication_inclusion?,
24
+ :test_post_search?,
25
+ :token_search_params,
26
+ :test_reference_variants?,
27
+ :params_with_comparators,
28
+ :multiple_or_search_params
29
+
30
+ def all_search_params
31
+ @all_search_params ||=
32
+ patient_id_list.each_with_object({}) do |patient_id, params|
33
+ params[patient_id] ||= []
34
+ new_params =
35
+ if fixed_value_search?
36
+ fixed_value_search_param_values.map { |value| fixed_value_search_params(value, patient_id) }
37
+ else
38
+ [search_params_with_values(search_param_names, patient_id)]
39
+ end
40
+ new_params.reject! do |params|
41
+ params.any? { |_key, value| value.blank? }
42
+ end
43
+
44
+ params[patient_id].concat(new_params)
45
+ end
46
+ end
47
+
48
+ def all_provenance_revinclude_search_params
49
+ @all_provenance_revinclude_search_params ||=
50
+ all_search_params.transform_values! do |params_list|
51
+ params_list.map { |params| params.merge(_revinclude: 'Provenance:target') }
52
+ end
53
+ end
54
+
55
+ def any_valid_search_params?(search_params)
56
+ search_params.any? { |_patient_id, params| params.present? }
57
+ end
58
+
59
+ def run_provenance_revinclude_search_test
60
+ # TODO: skip if not supported?
61
+ skip_if !any_valid_search_params?(all_provenance_revinclude_search_params), unable_to_resolve_params_message
62
+
63
+ provenance_resources =
64
+ all_provenance_revinclude_search_params.flat_map do |_patient_id, params_list|
65
+ params_list.flat_map do |params|
66
+ fhir_search resource_type, params:, tags: tags(params)
67
+
68
+ perform_search_with_status(params, patient_id) if response[:status] == 400 && possible_status_search?
69
+
70
+ check_search_response
71
+
72
+ # TODO: check that only provenance resources for resources matching
73
+ # granular scopes returned
74
+ fetch_and_assert_all_bundled_resources(additional_resource_types: ['Provenance'], params:)
75
+ .select { |resource| resource.resourceType == 'Provenance' }
76
+ end
77
+ end
78
+
79
+ scratch_provenance_resources[:all] ||= []
80
+ scratch_provenance_resources[:all].concat(provenance_resources)
81
+
82
+ save_delayed_references(provenance_resources, 'Provenance')
83
+
84
+ skip_if provenance_resources.empty?, no_resources_skip_message('Provenance')
85
+ end
86
+
87
+ def run_search_test
88
+ # TODO: skip if not supported?
89
+ skip_if !any_valid_search_params?(all_search_params), unable_to_resolve_params_message
90
+
91
+ resources_returned =
92
+ all_search_params.flat_map do |patient_id, params_list|
93
+ params_list.flat_map { |params| perform_search(params, patient_id) }
94
+ end
95
+
96
+ skip_if resources_returned.empty?, no_resources_skip_message
97
+
98
+ perform_multiple_or_search_test if multiple_or_search_params.present?
99
+ end
100
+
101
+ def perform_search(params, patient_id)
102
+ fhir_search resource_type, params:, tags: tags(params)
103
+
104
+ perform_search_with_status(params, patient_id) if response[:status] == 400 && possible_status_search?
105
+
106
+ check_search_response
107
+
108
+ resources_returned =
109
+ fetch_and_assert_all_bundled_resources(params:).select { |resource| resource.resourceType == resource_type }
110
+
111
+ return [] if resources_returned.blank?
112
+
113
+ perform_comparator_searches(params, patient_id) if params_with_comparators.present?
114
+
115
+ filter_conditions(resources_returned) if resource_type == 'Condition' && metadata.version == 'v5.0.1'
116
+ filter_devices(resources_returned) if resource_type == 'Device'
117
+
118
+ if first_search?
119
+ all_scratch_resources.concat(resources_returned).uniq!
120
+ scratch_resources_for_patient(patient_id).concat(resources_returned).uniq!
121
+ end
122
+
123
+ resources_returned.each do |resource|
124
+ check_resource_against_params(resource, params)
125
+ end
126
+
127
+ save_delayed_references(resources_returned) if saves_delayed_references?
128
+
129
+ return resources_returned if all_search_variants_tested?
130
+
131
+ perform_post_search(resources_returned, params) if test_post_search?
132
+ test_medication_inclusion(resources_returned, params, patient_id) if test_medication_inclusion?
133
+ perform_reference_with_type_search(params, resources_returned.count) if test_reference_variants?
134
+ perform_search_with_system(params, patient_id) if token_search_params.present?
135
+
136
+ resources_returned
137
+ end
138
+
139
+ def perform_post_search(get_search_resources, params)
140
+ fhir_search resource_type, params:, search_method: :post
141
+
142
+ check_search_response
143
+
144
+ post_search_resources = fetch_and_assert_all_bundled_resources.select do |resource|
145
+ resource.resourceType == resource_type
146
+ end
147
+
148
+ filter_conditions(post_search_resources) if resource_type == 'Condition' && metadata.version == 'v5.0.1'
149
+ filter_devices(post_search_resources) if resource_type == 'Device'
150
+
151
+ get_resource_count = get_search_resources.length
152
+ post_resource_count = post_search_resources.length
153
+
154
+ search_variant_test_records[:post_variant] = true
155
+
156
+ assert get_resource_count == post_resource_count,
157
+ 'Expected search by POST to return the same results as search by GET, ' \
158
+ "but GET search returned #{get_resource_count} resources, and POST search " \
159
+ "returned #{post_resource_count} resources."
160
+ end
161
+
162
+ def filter_devices(resources)
163
+ codes_to_include = implantable_device_codes&.split(',')&.map(&:strip)
164
+ return resources if codes_to_include.blank?
165
+
166
+ resources.select! do |resource|
167
+ resource&.type&.coding&.any? { |coding| codes_to_include.include?(coding.code) }
168
+ end
169
+ end
170
+
171
+ def excluded_code?(coding, codes_to_exclude)
172
+ codes_to_exclude.any? do |exclude_code|
173
+ if exclude_code.include?('|')
174
+ system, code = exclude_code.split('|')
175
+ coding.code == code && coding.system == system
176
+ else
177
+ code = exclude_code
178
+ coding.code == code
179
+ end
180
+ end
181
+ end
182
+
183
+ def filter_conditions(resources)
184
+ # HL7 JIRA FHIR-37917. US Core v5.0.1 does not required patient+category.
185
+ # In order to distinguish which resources matches the current profile, Inferno has to manually filter
186
+ # the result of first search, which is searching by patient.
187
+ resources.select! do |resource|
188
+ resource.category.any? do |category|
189
+ category.coding.any? do |coding|
190
+ metadata.search_definitions[:category][:values].include? coding.code
191
+ end
192
+ end
193
+ end
194
+ end
195
+
196
+ def filter_adi_document_reference(resources)
197
+ resources.select! do |resource|
198
+ resource.category.any? do |category|
199
+ category.coding.any? do |coding|
200
+ metadata.search_definitions[:category][:values].include? coding.code
201
+ end
202
+ end
203
+ end
204
+ end
205
+
206
+ def search_and_check_response(params, resource_type = self.resource_type)
207
+ fhir_search resource_type, params:, tags: tags(params)
208
+
209
+ check_search_response
210
+ end
211
+
212
+ def check_search_response
213
+ assert_response_status(200)
214
+ assert_resource_type(:bundle)
215
+ # NOTE: how do we want to handle validating Bundles?
216
+ end
217
+
218
+ def search_variant_test_records
219
+ @search_variant_test_records ||= initial_search_variant_test_records
220
+ end
221
+
222
+ def initial_search_variant_test_records
223
+ {}.tap do |records|
224
+ records[:post_variant] = false if test_post_search?
225
+ records[:medication_inclusion] = false if test_medication_inclusion?
226
+ records[:reference_variants] = false if test_reference_variants?
227
+ records[:token_variants] = false if token_search_params.present?
228
+ records[:comparator_searches] = Set.new if params_with_comparators.present?
229
+ end
230
+ end
231
+
232
+ def all_search_variants_tested?
233
+ search_variant_test_records.all? { |_variant, tested| tested.present? } &&
234
+ all_comparator_searches_tested?
235
+ end
236
+
237
+ def all_comparator_searches_tested?
238
+ return true if params_with_comparators.blank?
239
+
240
+ Set.new(params_with_comparators) == search_variant_test_records[:comparator_searches]
241
+ end
242
+
243
+ def date_comparator_value(comparator, date)
244
+ date = date.start || date.end if date.is_a? FHIR::Period
245
+ case comparator
246
+ when 'lt', 'le'
247
+ comparator + (DateTime.xmlschema(date) + 1).xmlschema
248
+ when 'gt', 'ge'
249
+ comparator + (DateTime.xmlschema(date) - 1).xmlschema
250
+ else
251
+ # ''
252
+ raise "Unsupported comparator '#{comparator}'"
253
+ end
254
+ end
255
+
256
+ def required_comparators(name)
257
+ metadata
258
+ .search_definitions
259
+ .dig(name.to_sym, :comparators)
260
+ .select { |_comparator, expectation| expectation == 'SHALL' }
261
+ .keys
262
+ .map(&:to_s)
263
+ end
264
+
265
+ def perform_comparator_searches(params, patient_id)
266
+ params_with_comparators.each do |name|
267
+ next if search_variant_test_records[:comparator_searches].include? name
268
+
269
+ required_comparators(name).each do |comparator|
270
+ paths = search_param_paths(name).first
271
+ date_element = find_a_value_at(scratch_resources_for_patient(patient_id), paths)
272
+ params_with_comparator = params.merge(name => date_comparator_value(comparator, date_element))
273
+
274
+ search_and_check_response(params_with_comparator)
275
+
276
+ fetch_and_assert_all_bundled_resources(params: params_with_comparator).each do |resource|
277
+ check_resource_against_params(resource, params_with_comparator) if resource.resourceType == resource_type
278
+ end
279
+ end
280
+
281
+ search_variant_test_records[:comparator_searches] << name
282
+ end
283
+ end
284
+
285
+ def perform_reference_with_type_search(params, resource_count)
286
+ return if resource_count.zero?
287
+ return if search_variant_test_records[:reference_variants]
288
+
289
+ new_search_params = params.merge('patient' => "Patient/#{params['patient']}")
290
+ search_and_check_response(new_search_params)
291
+
292
+ reference_with_type_resources =
293
+ fetch_and_assert_all_bundled_resources(params: new_search_params)
294
+ .select { |resource| resource.resourceType == resource_type }
295
+
296
+ filter_conditions(reference_with_type_resources) if resource_type == 'Condition' && metadata.version == 'v5.0.1'
297
+ filter_devices(reference_with_type_resources) if resource_type == 'Device'
298
+
299
+ new_resource_count = reference_with_type_resources.count
300
+
301
+ assert new_resource_count == resource_count,
302
+ "Expected search by `#{params['patient']}` to to return the same results as searching " \
303
+ "by `#{new_search_params['patient']}`, but found #{resource_count} resources with " \
304
+ "`#{params['patient']}` and #{new_resource_count} with `#{new_search_params['patient']}`"
305
+
306
+ search_variant_test_records[:reference_variants] = true
307
+ end
308
+
309
+ def perform_search_with_system(params, patient_id)
310
+ return if search_variant_test_records[:token_variants]
311
+
312
+ new_search_params = search_params_with_values(token_search_params, patient_id, include_system: true)
313
+ return if new_search_params.any? { |_name, value| value.blank? }
314
+
315
+ search_params = params.merge(new_search_params)
316
+ search_and_check_response(search_params)
317
+
318
+ resources_returned =
319
+ fetch_and_assert_all_bundled_resources(params: search_params)
320
+ .select { |resource| resource.resourceType == resource_type }
321
+
322
+ assert resources_returned.present?, 'No resources were returned when searching by `system|code`'
323
+
324
+ search_variant_test_records[:token_variants] = true
325
+ end
326
+
327
+ def perform_search_with_status(
328
+ original_params,
329
+ _patient_id,
330
+ status_search_values: self.status_search_values,
331
+ resource_type: self.resource_type
332
+ )
333
+ assert resource.is_a?(FHIR::OperationOutcome), 'Server returned a status of 400 without an OperationOutcome'
334
+ # TODO: warn about documenting status requirements
335
+ status_search_values.flat_map do |status_value|
336
+ search_params = original_params.merge("#{status_search_param_name}": status_value)
337
+
338
+ search_and_check_response(search_params)
339
+
340
+ entries = resource.entry.select { |entry| entry.resource.resourceType == resource_type }
341
+
342
+ if entries.present?
343
+ original_params.merge!("#{status_search_param_name}": status_value)
344
+ break
345
+ end
346
+ end
347
+ end
348
+
349
+ def status_search_param_name
350
+ @status_search_param_name ||=
351
+ metadata.search_definitions.keys.find { |key| key.to_s.include? 'status' }
352
+ end
353
+
354
+ def status_search_values
355
+ default_search_values(status_search_param_name)
356
+ end
357
+
358
+ def default_search_values(param_name)
359
+ definition = metadata.search_definitions[param_name]
360
+ return [] if definition.blank?
361
+
362
+ definition[:multiple_or] == 'SHALL' ? [definition[:values].join(',')] : Array.wrap(definition[:values])
363
+ end
364
+
365
+ def perform_multiple_or_search_test
366
+ resolved_one = false
367
+
368
+ all_search_params.each do |patient_id, params_list|
369
+ next unless params_list.present?
370
+
371
+ search_params = params_list.first
372
+ existing_values = {}
373
+ missing_values = {}
374
+
375
+ multiple_or_search_params.each do |param_name|
376
+ search_value = default_search_values(param_name.to_sym)
377
+ search_params = search_params.merge(param_name.to_s => search_value)
378
+ existing_values[param_name.to_sym] =
379
+ scratch_resources_for_patient(patient_id).map(&param_name.to_sym).compact.uniq
380
+ end
381
+
382
+ # skip patient without multiple-or values
383
+ next if existing_values.values.any?(&:empty?)
384
+
385
+ resolved_one = true
386
+
387
+ search_and_check_response(search_params)
388
+
389
+ resources_returned =
390
+ fetch_and_assert_all_bundled_resources(params: search_params)
391
+ .select { |resource| resource.resourceType == resource_type }
392
+
393
+ multiple_or_search_params.each do |param_name|
394
+ missing_values[param_name.to_sym] =
395
+ existing_values[param_name.to_sym] - resources_returned.map(&param_name.to_sym)
396
+ end
397
+
398
+ missing_value_message = missing_values
399
+ .reject { |_param_name, missing_value| missing_value.empty? }
400
+ .map { |param_name, missing_value| "#{missing_value.join(',')} values from #{param_name}" }
401
+ .join(' and ')
402
+
403
+ assert missing_value_message.blank?,
404
+ "Could not find #{missing_value_message} in any of the resources returned for Patient/#{patient_id}"
405
+
406
+ break if resolved_one
407
+ end
408
+ end
409
+
410
+ def test_medication_inclusion(base_resources, params, patient_id)
411
+ return if search_variant_test_records[:medication_inclusion]
412
+
413
+ scratch[:medication_resources] ||= {}
414
+ scratch[:medication_resources][:all] ||= []
415
+ scratch[:medication_resources][patient_id] ||= []
416
+ scratch[:medication_resources][:contained] ||= []
417
+
418
+ base_resources_with_external_reference =
419
+ base_resources
420
+ .select { |request| request&.medicationReference&.present? }
421
+ .reject { |request| request&.medicationReference&.reference&.start_with? '#' }
422
+
423
+ contained_medications =
424
+ base_resources
425
+ .select { |request| request&.medicationReference&.reference&.start_with? '#' }
426
+ .flat_map(&:contained)
427
+ .select { |resource| resource.resourceType == 'Medication' }
428
+
429
+ scratch[:medication_resources][:all] += contained_medications
430
+ scratch[:medication_resources][patient_id] += contained_medications
431
+ scratch[:medication_resources][:contained] += contained_medications
432
+
433
+ return if base_resources_with_external_reference.blank?
434
+
435
+ search_params = params.merge(_include: "#{resource_type}:medication")
436
+
437
+ search_and_check_response(search_params)
438
+
439
+ medications =
440
+ fetch_and_assert_all_bundled_resources(params: search_params)
441
+ .select { |resource| resource.resourceType == 'Medication' }
442
+ assert medications.present?, 'No Medications were included in the search results'
443
+
444
+ included_medications = medications.map { |medication| "#{medication.resourceType}/#{medication.id}" }
445
+
446
+ matched_base_resources = base_resources_with_external_reference.select do |base_resource|
447
+ included_medications.any? do |medication_reference|
448
+ is_reference_match?(base_resource.medicationReference.reference, medication_reference)
449
+ end
450
+ end
451
+
452
+ not_matched_included_medications = included_medications.select do |medication_reference|
453
+ matched_base_resources.none? do |base_resource|
454
+ is_reference_match?(base_resource.medicationReference.reference, medication_reference)
455
+ end
456
+ end
457
+
458
+ not_matched_included_medications_string = not_matched_included_medications.join(',')
459
+ assert not_matched_included_medications.empty?,
460
+ "No #{resource_type} references #{not_matched_included_medications_string} in the search result."
461
+
462
+ medications.uniq!(&:id)
463
+
464
+ scratch[:medication_resources][:all] += medications
465
+ scratch[:medication_resources][patient_id] += medications
466
+
467
+ search_variant_test_records[:medication_inclusion] = true
468
+ end
469
+
470
+ def is_reference_match?(reference, local_reference)
471
+ regex_pattern = %r{^(#{Regexp.escape(local_reference)}|\S+/#{Regexp.escape(local_reference)}(?:[/|]\S+)*)$}
472
+ reference.match?(regex_pattern)
473
+ end
474
+
475
+ def all_scratch_resources
476
+ scratch_resources[:all] ||= []
477
+ end
478
+
479
+ def scratch_resources_for_patient(patient_id)
480
+ return all_scratch_resources if patient_id.nil?
481
+
482
+ scratch_resources[patient_id] ||= []
483
+ end
484
+
485
+ def references_to_save(resource_type = nil)
486
+ reference_metadata = resource_type == 'Provenance' ? provenance_metadata : metadata
487
+ reference_metadata.delayed_references
488
+ end
489
+
490
+ def fixed_value_search_param_name
491
+ (search_param_names - ['patient']).first
492
+ end
493
+
494
+ def fixed_value_search_param_values
495
+ metadata.search_definitions[fixed_value_search_param_name.to_sym][:values]
496
+ end
497
+
498
+ def fixed_value_search_params(value, patient_id)
499
+ search_param_names.each_with_object({}) do |name, params|
500
+ params[name] = patient_id_param?(name) ? patient_id : value
501
+ end
502
+ end
503
+
504
+ def search_params_with_values(search_param_names, patient_id, include_system: false)
505
+ resources = scratch_resources_for_patient(patient_id)
506
+
507
+ if resources.empty?
508
+ return search_param_names.each_with_object({}) do |name, params|
509
+ value = patient_id_param?(name) ? patient_id : nil
510
+ params[name] = value
511
+ end
512
+ end
513
+
514
+ resources.each_with_object({}) do |resource, outer_params|
515
+ results_from_one_resource = search_param_names.each_with_object({}) do |name, params|
516
+ value = if patient_id_param?(name)
517
+ patient_id
518
+ else
519
+ search_param_value(name, resource,
520
+ include_system: include_system)
521
+ end
522
+ params[name] = value
523
+ end
524
+
525
+ outer_params.merge!(results_from_one_resource)
526
+
527
+ # stop if all parameter values are found
528
+ return outer_params if outer_params.all? { |_key, value| value.present? }
529
+ end
530
+ end
531
+
532
+ def patient_id_list
533
+ return [nil] unless respond_to? :patient_ids
534
+
535
+ patient_ids.split(',').map(&:strip)
536
+ end
537
+
538
+ def patient_search?
539
+ search_param_names.any? { |name| patient_id_param? name }
540
+ end
541
+
542
+ def patient_id_param?(name)
543
+ name == 'patient' || (name == '_id' && resource_type == 'Patient')
544
+ end
545
+
546
+ def search_param_paths(name)
547
+ paths = metadata.search_definitions[name.to_sym][:paths]
548
+ paths[0] = 'local_class' if paths.first == 'class'
549
+
550
+ paths
551
+ end
552
+
553
+ def all_search_params_present?(params)
554
+ params.all? { |_name, value| value.present? }
555
+ end
556
+
557
+ def array_of_codes(array)
558
+ array.map { |name| "`#{name}`" }.join(', ')
559
+ end
560
+
561
+ def unable_to_resolve_params_message
562
+ "Could not find values for all search params #{array_of_codes(search_param_names)}"
563
+ end
564
+
565
+ def empty_search_params_message(empty_search_params)
566
+ "Could not find values for the search parameters #{array_of_codes(empty_search_params.keys)}"
567
+ end
568
+
569
+ def no_resources_skip_message(resource_type = self.resource_type)
570
+ msg = "No #{resource_type} resources appear to be available"
571
+
572
+ if resource_type == 'Device' && implantable_device_codes.present?
573
+ msg.concat(" with the following Device Type Code filter: #{implantable_device_codes}")
574
+ end
575
+
576
+ msg + '. Please use patients with more information'
577
+ end
578
+
579
+ def fetch_and_assert_all_bundled_resources(
580
+ resource_type: self.resource_type,
581
+ reply_handler: nil,
582
+ max_pages: 20,
583
+ additional_resource_types: [],
584
+ params: nil
585
+ )
586
+ tags = tags(params)
587
+ bundle = resource
588
+ additional_resource_types << 'Medication' if %w[MedicationRequest MedicationDispense].include?(resource_type)
589
+
590
+ assert_handler = proc do |response|
591
+ assert_response_status(200, response: response)
592
+ assert_valid_json(response[:body], "Could not resolve bundle as JSON: #{response[:body]}")
593
+ end
594
+
595
+ reply_and_assert_handler = if reply_handler
596
+ proc do |response|
597
+ assert_handler.call(response)
598
+ reply_handler.call(response)
599
+ end
600
+ else
601
+ assert_handler
602
+ end
603
+
604
+ fetch_all_bundled_resources(resource_type:, bundle:, reply_handler: reply_and_assert_handler, max_pages:,
605
+ additional_resource_types:, tags:)
606
+ end
607
+
608
+ def prefer_well_known_code_system(element, include_system)
609
+ coding =
610
+ find_a_value_at(element, 'coding') { |c| c.code.present? && WellKnownCodeSystems.include?(c.system) }
611
+
612
+ return coding if coding.present?
613
+
614
+ find_a_value_at(element, 'coding') { |c| c.code.present? && (!include_system || c.system.present?) }
615
+ end
616
+
617
+ def search_param_value(name, resource, include_system: false)
618
+ paths = search_param_paths(name)
619
+ search_value = nil
620
+ paths.each do |path|
621
+ element = find_a_value_at(resource, path) { |element| element_has_valid_value?(element, include_system) }
622
+
623
+ search_value =
624
+ case element
625
+ when FHIR::Period
626
+ if element.start.present?
627
+ 'gt' + (DateTime.xmlschema(element.start) - 1).xmlschema
628
+ else
629
+ end_datetime = get_fhir_datetime_range(element.end)[:end]
630
+ 'lt' + (end_datetime + 1).xmlschema
631
+ end
632
+ when FHIR::Reference
633
+ element.reference
634
+ when FHIR::CodeableConcept
635
+ coding = prefer_well_known_code_system(element, include_system)
636
+ include_system ? "#{coding.system}|#{coding.code}" : coding.code
637
+ when FHIR::Identifier
638
+ include_system ? "#{element.system}|#{element.value}" : element.value
639
+ when FHIR::Coding
640
+ include_system ? "#{element.system}|#{element.code}" : element.code
641
+ when FHIR::HumanName
642
+ element.family || element.given&.first || element.text
643
+ when FHIR::Address
644
+ element.text || element.city || element.state || element.postalCode || element.country
645
+ when Inferno::DSL::PrimitiveType
646
+ element.value
647
+ else
648
+ if metadata.version != 'v3.1.1' &&
649
+ metadata.search_definitions[name.to_sym][:type] == 'date' &&
650
+ params_with_comparators&.include?(name)
651
+ # convert date search to greath-than comparator search with correct precision
652
+ # For all date search parameters:
653
+ # Patient.birthDate does not mandate comparators so cannot be converted
654
+ # Goal.target-date has day precision
655
+ # All others have second + time offset precision
656
+ if /^\d{4}(-\d{2})?$/.match?(element) || # YYYY or YYYY-MM
657
+ (/^\d{4}-\d{2}-\d{2}$/.match?(element) && resource_type != 'Goal') # YYY-MM-DD AND Resource is NOT Goal
658
+ "gt#{(DateTime.xmlschema(element) - 1).xmlschema}"
659
+ else
660
+ element
661
+ end
662
+ else
663
+ element
664
+ end
665
+ end
666
+
667
+ break if search_value.present?
668
+ end
669
+
670
+ search_value&.gsub(',', '\\,')
671
+ end
672
+
673
+ def element_has_valid_value?(element, include_system)
674
+ case element
675
+ when FHIR::Reference
676
+ element.reference.present?
677
+ when FHIR::CodeableConcept
678
+ coding = prefer_well_known_code_system(element, include_system)
679
+ coding.present?
680
+ when FHIR::Identifier
681
+ include_system ? element.value.present? && element.system.present? : element.value.present?
682
+ when FHIR::Coding
683
+ include_system ? element.code.present? && element.system.present? : element.code.present?
684
+ when FHIR::HumanName
685
+ (element.family || element.given&.first || element.text).present?
686
+ when FHIR::Address
687
+ (element.text || element.city || element.state || element.postalCode || element.country).present?
688
+ when Inferno::DSL::PrimitiveType
689
+ element.value.present?
690
+ else
691
+ true
692
+ end
693
+ end
694
+
695
+ def save_resource_reference(resource_type, reference, referencing_resource)
696
+ scratch[:references] ||= {}
697
+ scratch[:references][resource_type] ||= Set.new
698
+ scratch[:references][resource_type] << { reference: reference, referencing_resource: referencing_resource }
699
+ end
700
+
701
+ def save_delayed_references(resources, containing_resource_type = resource_type)
702
+ resources.each do |resource|
703
+ references_to_save(containing_resource_type).each do |reference_to_save|
704
+ resolve_path(resource, reference_to_save[:path])
705
+ .select do |reference|
706
+ reference.is_a?(FHIR::Reference) &&
707
+ !reference.contained? && reference.reference.present?
708
+ end
709
+ .each do |reference|
710
+ resource_type = reference.resource_class.name.demodulize
711
+ need_to_save = reference_to_save[:resources].include?(resource_type)
712
+ next unless need_to_save
713
+
714
+ reference_resource_type = resource.resourceType
715
+ reference_resource_id = resource.id
716
+
717
+ referencing_resource = "#{reference_resource_type}/#{reference_resource_id}"
718
+
719
+ save_resource_reference(resource_type, reference, referencing_resource)
720
+ end
721
+ end
722
+ end
723
+ end
724
+
725
+ #### RESULT CHECKING ####
726
+
727
+ def check_resource_against_params(resource, params)
728
+ params.each do |name, escaped_search_value|
729
+ values_found = []
730
+ search_value = unescape_search_value(escaped_search_value)
731
+
732
+ match_found = resource_matches_param?(resource, name, escaped_search_value, values_found)
733
+
734
+ assert match_found,
735
+ "#{resource_type}/#{resource.id} did not match the search parameters:\n" \
736
+ "* Expected: #{unescape_search_value(search_value)}\n" \
737
+ "* Found: #{values_found.map(&:inspect).join(', ')}"
738
+ end
739
+ end
740
+
741
+ def unescape_search_value(value)
742
+ value&.gsub('\\,', ',')
743
+ end
744
+
745
+ def resource_matches_param?(resource, search_param_name, escaped_search_value, values_found = [])
746
+ search_value = unescape_search_value(escaped_search_value)
747
+ paths = search_param_paths(search_param_name)
748
+
749
+ match_found = false
750
+
751
+ paths.each do |path|
752
+ type = metadata.search_definitions[search_param_name.to_sym][:type]
753
+
754
+ resolve_path(resource, path).each do |value|
755
+ values_found <<
756
+ if value.is_a? FHIR::Reference
757
+ value.reference
758
+ elsif value.is_a? Inferno::DSL::PrimitiveType
759
+ value.value
760
+ else
761
+ value
762
+ end
763
+ end
764
+
765
+ values_found.compact!
766
+ match_found =
767
+ case type
768
+ when 'Period', 'date', 'instant', 'dateTime'
769
+ values_found.any? { |date| validate_date_search(search_value, date) }
770
+ when 'HumanName'
771
+ # When a string search parameter refers to the types HumanName and Address,
772
+ # the search covers the elements of type string, and does not cover elements such as use and period
773
+ # https://www.hl7.org/fhir/search.html#string
774
+ search_value_downcase = search_value.downcase
775
+ values_found.any? do |name|
776
+ name&.text&.downcase&.start_with?(search_value_downcase) ||
777
+ name&.family&.downcase&.start_with?(search_value_downcase) ||
778
+ name&.given&.any? { |given| given.downcase.start_with?(search_value_downcase) } ||
779
+ name&.prefix&.any? { |prefix| prefix.downcase.start_with?(search_value_downcase) } ||
780
+ name&.suffix&.any? { |suffix| suffix.downcase.start_with?(search_value_downcase) }
781
+ end
782
+ when 'Address'
783
+ search_value_downcase = search_value.downcase
784
+ values_found.any? do |address|
785
+ address&.text&.downcase&.start_with?(search_value_downcase) ||
786
+ address&.city&.downcase&.start_with?(search_value_downcase) ||
787
+ address&.state&.downcase&.start_with?(search_value_downcase) ||
788
+ address&.postalCode&.downcase&.start_with?(search_value_downcase) ||
789
+ address&.country&.downcase&.start_with?(search_value_downcase)
790
+ end
791
+ when 'CodeableConcept'
792
+ # FHIR token search (https://www.hl7.org/fhir/search.html#token): "When in doubt, servers SHOULD
793
+ # treat tokens in a case-insensitive manner, on the grounds that including undesired data has
794
+ # less safety implications than excluding desired behavior".
795
+ codings = values_found.flat_map(&:coding)
796
+ if search_value.include? '|'
797
+ system = search_value.split('|').first
798
+ code = search_value.split('|').last
799
+ codings&.any? { |coding| coding.system == system && coding.code&.casecmp?(code) }
800
+ else
801
+ codings&.any? { |coding| coding.code&.casecmp?(search_value) }
802
+ end
803
+ when 'Coding'
804
+ if search_value.include? '|'
805
+ system = search_value.split('|').first
806
+ code = search_value.split('|').last
807
+ values_found.any? { |coding| coding.system == system && coding.code&.casecmp?(code) }
808
+ else
809
+ values_found.any? { |coding| coding.code&.casecmp?(search_value) }
810
+ end
811
+ when 'Identifier'
812
+ if search_value.include? '|'
813
+ values_found.any? { |identifier| "#{identifier.system}|#{identifier.value}" == search_value }
814
+ else
815
+ values_found.any? { |identifier| identifier.value == search_value }
816
+ end
817
+ when 'string'
818
+ searched_values = search_value.downcase.split(/(?<!\\\\),/).map { |string| string.gsub('\\,', ',') }
819
+ values_found.any? do |value_found|
820
+ searched_values.any? { |searched_value| value_found.downcase.starts_with? searched_value }
821
+ end
822
+ else
823
+ # searching by patient requires special case because we are searching by a resource identifier
824
+ # references can also be URLs, so we may need to resolve those URLs
825
+ if %w[subject patient].include? search_param_name.to_s
826
+ id = search_value.split('Patient/').last
827
+ possible_values = [id, "Patient/#{id}", "#{url}/Patient/#{id}"]
828
+ values_found.any? do |reference|
829
+ possible_values.include? reference
830
+ end
831
+ else
832
+ search_values = search_value.split(/(?<!\\\\),/).map { |string| string.gsub('\\,', ',') }
833
+ values_found.any? { |value_found| search_values.include? value_found }
834
+ end
835
+ end
836
+
837
+ break if match_found
838
+ end
839
+
840
+ match_found
841
+ end
842
+
843
+ def tags(params)
844
+ return nil unless config.options[:tag_requests]
845
+
846
+ return nil if params.blank?
847
+
848
+ if %w[Condition DiagnosticReport DocumentReference Observation ServiceRequest].include? resource_type
849
+ return [search_params_tag(params)]
850
+ end
851
+
852
+ nil
853
+ end
854
+
855
+ def search_params_tag(params)
856
+ "#{resource_type}?#{params.keys.join('&')}"
857
+ end
858
+ end
859
+ end