Class: MedicalRecords::Client

Inherits:
Common::Client::Base show all
Includes:
Common::Client::Concerns::MhvFhirSessionClient
Defined in:
lib/medical_records/client.rb

Overview

Core class responsible for Medical Records API interface operations

Constant Summary collapse

DEFAULT_COUNT =

Default number of records to request per call when searching

9999
PHYSICIAN_PROCEDURE_NOTE =

LOINC codes for clinical notes

'11506-3'
DISCHARGE_SUMMARY =

Physician procedure note

'18842-5'
CONSULT_RESULT =

Discharge summary

'11488-4'
BLOOD_PRESSURE =

LOINC codes for vitals

'85354-9'
BREATHING_RATE =

Blood Pressure

'9279-1'
HEART_RATE =

Breathing Rate

'8867-4'
HEIGHT =

Heart Rate

'8302-2'
TEMPERATURE =

Height

'8310-5'
WEIGHT =

Temperature

'29463-7'
PULSE_OXIMETRY =

Weight

'59408-5,2708-6, '
MICROBIOLOGY =

LOINC codes for labs & tests

'79381-0'
PATHOLOGY =

Gastrointestinal pathogens panel

'60567-5'
EKG =

Comprehensive pathology report panel

'11524-6'
RADIOLOGY =

EKG Study

'18748-4'

Constants included from Common::Client::Concerns::MhvFhirSessionClient

Common::Client::Concerns::MhvFhirSessionClient::LOCK_RETRY_DELAY, Common::Client::Concerns::MhvFhirSessionClient::RETRY_ATTEMPTS

Constants included from Common::Client::Concerns::MhvLockedSessionClient

Common::Client::Concerns::MhvLockedSessionClient::LOCK_RETRY_DELAY, Common::Client::Concerns::MhvLockedSessionClient::RETRY_ATTEMPTS

Instance Attribute Summary

Attributes included from Common::Client::Concerns::MhvFhirSessionClient

#session

Attributes included from Common::Client::Concerns::MHVJwtSessionClient

#session

Attributes included from Common::Client::Concerns::MhvLockedSessionClient

#session

Instance Method Summary collapse

Methods included from Common::Client::Concerns::MhvFhirSessionClient

#fetch_patient_by_subject_id, #get_patient_fhir_id, #get_session, #incomplete?, #invalid?, #perform_phr_refresh, #save_session

Methods included from Common::Client::Concerns::MHVJwtSessionClient

#auth_body, #auth_headers, #decode_jwt_token, #extract_token_expiration, #get_jwt_from_headers, #get_session, #get_session_tagged, #jwt_bearer_token, #patient_fhir_id, #session_config_key, #user_key, #validate_session_params

Methods included from Common::Client::Concerns::MhvLockedSessionClient

#authenticate, #initialize, #invalid?, #lock_and_get_session, #obtain_redis_lock, #refresh_session, #release_redis_lock

Methods included from SentryLogging

#log_exception_to_sentry, #log_message_to_sentry, #non_nil_hash?, #normalize_level, #rails_logger, #set_sentry_metadata

Methods inherited from Common::Client::Base

#config, configuration, #connection, #delete, #get, #perform, #post, #put, #raise_backend_exception, #raise_not_authenticated, #request, #sanitize_headers!, #service_name

Instance Method Details

#base_pathString

Returns Base path for dependent URLs.

Returns:

  • (String)

    Base path for dependent URLs



45
46
47
# File 'lib/medical_records/client.rb', line 45

def base_path
  "#{Settings.mhv.medical_records.host}/fhir/"
end

#fetch_nested_value(object, field_path) ⇒ Object (protected)

Fetches the value of a potentially nested field from a given object.

Parameters:

  • object (Object)

    the object to fetch the value from

  • field_path (String)

    the dot-separated path to the field



341
342
343
344
345
# File 'lib/medical_records/client.rb', line 341

def fetch_nested_value(object, field_path)
  field_path.split('.').reduce(object) do |obj, method|
    obj.respond_to?(method) ? obj.send(method) : nil
  end
end

#fhir_clientFHIR::Client

Create a new FHIR::Client instance based on the client_session. Use an existing client if one already exists in this instance.

Returns:

  • (FHIR::Client)

Raises:



74
75
76
77
78
# File 'lib/medical_records/client.rb', line 74

def fhir_client
  raise MedicalRecords::PatientNotFound if patient_fhir_id.nil?

  @fhir_client ||= sessionless_fhir_client(jwt_bearer_token)
end

#fhir_read(fhir_model, id) ⇒ Object (protected)



226
227
228
229
230
# File 'lib/medical_records/client.rb', line 226

def fhir_read(fhir_model, id)
  result = fhir_client.read(fhir_model, id)
  handle_api_errors(result) if result.resource.nil?
  result.resource
end

#fhir_search(fhir_model, params) ⇒ FHIR::Bundle (protected)

Perform a FHIR search. This method will continue making queries until all results have been returned.

Parameters:

  • fhir_model (FHIR::Model)

    The type of resource to search

  • params (Hash)

    The parameters to pass the search

Returns:

  • (FHIR::Bundle)


196
197
198
199
200
201
202
203
204
205
206
# File 'lib/medical_records/client.rb', line 196

def fhir_search(fhir_model, params)
  reply = fhir_search_query(fhir_model, params)
  combined_bundle = reply.resource
  loop do
    break unless reply.resource.next_link

    reply = fhir_client.next_page(reply)
    combined_bundle = merge_bundles(combined_bundle, reply.resource)
  end
  combined_bundle
end

#fhir_search_query(fhir_model, params) ⇒ FHIR::ClientReply (protected)

Perform a FHIR search. Returns the first page of results only.

Parameters:

  • fhir_model (FHIR::Model)

    The type of resource to search

  • params (Hash)

    The parameters to pass the search

Returns:

  • (FHIR::ClientReply)


215
216
217
218
219
220
221
222
223
224
# File 'lib/medical_records/client.rb', line 215

def fhir_search_query(fhir_model, params)
  default_headers = { 'Cache-Control': 'no-cache' }
  params[:headers] = default_headers.merge(params.fetch(:headers, {}))

  params[:search][:parameters].merge!(_count: DEFAULT_COUNT)

  result = fhir_client.search(fhir_model, params)
  handle_api_errors(result) if result.resource.nil?
  result
end

#get_allergy(allergy_id) ⇒ Object



102
103
104
# File 'lib/medical_records/client.rb', line 102

def get_allergy(allergy_id)
  fhir_read(FHIR::AllergyIntolerance, allergy_id)
end

#get_clinical_note(note_id) ⇒ Object



160
161
162
# File 'lib/medical_records/client.rb', line 160

def get_clinical_note(note_id)
  fhir_read(FHIR::DocumentReference, note_id)
end

#get_condition(condition_id) ⇒ Object



133
134
135
# File 'lib/medical_records/client.rb', line 133

def get_condition(condition_id)
  fhir_read(FHIR::Condition, condition_id)
end

#get_diagnostic_report(record_id) ⇒ Object



170
171
172
# File 'lib/medical_records/client.rb', line 170

def get_diagnostic_report(record_id)
  fhir_read(FHIR::DiagnosticReport, record_id)
end

#get_patient_by_identifier(fhir_client, identifier) ⇒ Object



80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/medical_records/client.rb', line 80

def get_patient_by_identifier(fhir_client, identifier)
  result = fhir_client.search(FHIR::Patient, {
                                search: { parameters: { identifier: } },
                                headers: { 'Cache-Control': 'no-cache' }
                              })

  # MHV will return a 202 if and only if the patient does not exist. It will not return 202 for
  # multiple patients found.
  raise MedicalRecords::PatientNotFound if result.response[:code] == 202

  resource = result.resource
  handle_api_errors(result) if resource.nil?
  resource
end

#get_vaccine(vaccine_id) ⇒ Object



112
113
114
# File 'lib/medical_records/client.rb', line 112

def get_vaccine(vaccine_id)
  fhir_read(FHIR::Immunization, vaccine_id)
end

#handle_api_errors(result) ⇒ Object (protected)



232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/medical_records/client.rb', line 232

def handle_api_errors(result)
  if result.code.present? && result.code >= 400
    body = JSON.parse(result.body)
    diagnostics = body['issue']&.first&.fetch('diagnostics', nil)
    diagnostics = "Error fetching data#{": #{diagnostics}" if diagnostics}"

    # Special-case exception handling
    if result.code == 500 && diagnostics.include?('HAPI-1363')
      # "HAPI-1363: Either No patient or multiple patient found"
      raise MedicalRecords::PatientNotFound
    end

    # Default exception handling
    raise Common::Exceptions::BackendServiceException.new(
      "MEDICALRECORDS_#{result.code}",
      status: result.code,
      detail: diagnostics,
      source: self.class.to_s
    )
  end
end

#list_allergiesObject



95
96
97
98
99
100
# File 'lib/medical_records/client.rb', line 95

def list_allergies
  bundle = fhir_search(FHIR::AllergyIntolerance,
                       search: { parameters: { patient: patient_fhir_id, 'clinical-status': 'active',
                                               'verification-status:not': 'entered-in-error' } })
  sort_bundle(bundle, :recordedDate, :desc)
end

#list_clinical_notesObject



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/medical_records/client.rb', line 137

def list_clinical_notes
  loinc_codes = "#{PHYSICIAN_PROCEDURE_NOTE},#{DISCHARGE_SUMMARY},#{CONSULT_RESULT}"
  bundle = fhir_search(FHIR::DocumentReference,
                       search: { parameters: { patient: patient_fhir_id, type: loinc_codes,
                                               'status:not': 'entered-in-error' } })

  # Sort the bundle of notes based on the date field appropriate to each note type.
  sort_bundle_with_criteria(bundle, :desc) do |resource|
    loinc_code = if resource.respond_to?(:type) && resource.type.respond_to?(:coding)
                   resource.type.coding&.find do |coding|
                     coding.respond_to?(:system) && coding.system == 'http://loinc.org'
                   end&.code
                 end

    case loinc_code
    when PHYSICIAN_PROCEDURE_NOTE, CONSULT_RESULT
      resource.date
    when DISCHARGE_SUMMARY
      resource.context&.period&.end
    end
  end
end

#list_conditionsObject



126
127
128
129
130
131
# File 'lib/medical_records/client.rb', line 126

def list_conditions
  bundle = fhir_search(FHIR::Condition,
                       search: { parameters: { patient: patient_fhir_id,
                                               'verification-status:not': 'entered-in-error' } })
  sort_bundle(bundle, :recordedDate, :desc)
end

#list_labs_and_testsObject



164
165
166
167
168
# File 'lib/medical_records/client.rb', line 164

def list_labs_and_tests
  bundle = fhir_search(FHIR::DiagnosticReport,
                       search: { parameters: { patient: patient_fhir_id, 'status:not': 'entered-in-error' } })
  sort_bundle(bundle, :effectiveDateTime, :desc)
end

#list_labs_document_referenceFHIR::Bundle (protected)

Fetch EKG and Radiology results for the given patient

Parameters:

  • patient_id (Fixnum)

    MHV patient ID

Returns:

  • (FHIR::Bundle)


182
183
184
185
186
187
# File 'lib/medical_records/client.rb', line 182

def list_labs_document_reference
  loinc_codes = "#{EKG},#{RADIOLOGY}"
  fhir_search(FHIR::DocumentReference,
              search: { parameters: { patient: patient_fhir_id, type: loinc_codes,
                                      'status:not': 'entered-in-error' } })
end

#list_vaccinesObject



106
107
108
109
110
# File 'lib/medical_records/client.rb', line 106

def list_vaccines
  bundle = fhir_search(FHIR::Immunization,
                       search: { parameters: { patient: patient_fhir_id, 'status:not': 'entered-in-error' } })
  sort_bundle(bundle, :occurrenceDateTime, :desc)
end

#list_vitalsObject

Function args are accepted and ignored for compatibility with MedicalRecords::LighthouseClient



117
118
119
120
121
122
123
124
# File 'lib/medical_records/client.rb', line 117

def list_vitals(*)
  # loinc_codes =
  #   "#{BLOOD_PRESSURE},#{BREATHING_RATE},#{HEART_RATE},#{HEIGHT},#{TEMPERATURE},#{WEIGHT},#{PULSE_OXIMETRY}"
  bundle = fhir_search(FHIR::Observation,
                       search: { parameters: { patient: patient_fhir_id, category: 'vital-signs',
                                               'status:not': 'entered-in-error' } })
  sort_bundle(bundle, :effectiveDateTime, :desc)
end

#merge_bundles(bundle1, bundle2) ⇒ Object (protected)

Merge two FHIR bundles into one, with an updated total count.

Parameters:

  • bundle1 (FHIR:Bundle)

    The first FHIR bundle

  • bundle2 (FHIR:Bundle)

    The second FHIR bundle

  • page_num (FHIR:Bundle)


261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'lib/medical_records/client.rb', line 261

def merge_bundles(bundle1, bundle2)
  unless bundle1.resourceType == 'Bundle' && bundle2.resourceType == 'Bundle'
    raise 'Both inputs must be FHIR Bundles'
  end

  # Clone the first bundle to avoid modifying the original
  merged_bundle = bundle1.clone

  # Merge the entries from the second bundle into the merged_bundle
  merged_bundle.entry ||= []
  bundle2.entry&.each do |entry|
    merged_bundle.entry << entry
  end

  # Update the total count in the merged bundle
  merged_bundle.total = merged_bundle.entry.count

  merged_bundle
end

#paginate_bundle_entries(entries, page_size, page_num) ⇒ Object (protected)

Apply pagination to the entries in a FHIR::Bundle object. This assumes sorting has already taken place.

Parameters:

  • entries

    a list of FHIR objects

  • page_size (Fixnum)

    page size

  • page_num (Fixnum)

    which page to return



288
289
290
291
292
293
294
295
# File 'lib/medical_records/client.rb', line 288

def paginate_bundle_entries(entries, page_size, page_num)
  start_index = (page_num - 1) * page_size
  end_index = start_index + page_size
  paginated_entries = entries[start_index...end_index]

  # Return the paginated result or an empty array if no entries
  paginated_entries || []
end

#sessionless_fhir_client(bearer_token) ⇒ FHIR::Client

Create a new FHIR::Client instance, given the provided bearer token. This method does not require a client_session to have been initialized.

Parameters:

  • bearer_token (String)

    The bearer token from the authentication call

Returns:

  • (FHIR::Client)


56
57
58
59
60
61
62
63
64
65
66
# File 'lib/medical_records/client.rb', line 56

def sessionless_fhir_client(bearer_token)
  # FHIR debug level is extremely verbose, printing the full contents of every response body.
  ::FHIR.logger.level = Logger::INFO

  FHIR::Client.new(base_path).tap do |client|
    client.use_r4
    client.default_json
    client.use_minimal_preference
    client.set_bearer_token(bearer_token)
  end
end

#sort_bundle(bundle, field, order = :asc) ⇒ Object (protected)

Sort the FHIR::Bundle entries on a given field and sort order. If a field is not present, that entry is sorted to the end.

Parameters:

  • bundle (FHIR::Bundle)

    the bundle to sort

  • field (Symbol, String)

    the field to sort on (supports nested fields with dot notation)

  • order (Symbol) (defaults to: :asc)

    the sort order, :asc (default) or :desc



305
306
307
308
309
310
# File 'lib/medical_records/client.rb', line 305

def sort_bundle(bundle, field, order = :asc)
  field = field.to_s
  sort_bundle_with_criteria(bundle, order) do |resource|
    fetch_nested_value(resource, field)
  end
end

#sort_bundle_with_criteria(bundle, order = :asc) ⇒ Object (protected)

Sort the FHIR::Bundle entries based on a provided block. The block should handle different resource types and define how to extract the sorting value from each.

Parameters:

  • bundle (FHIR::Bundle)

    the bundle to sort

  • order (Symbol) (defaults to: :asc)

    the sort order, :asc (default) or :desc



319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
# File 'lib/medical_records/client.rb', line 319

def sort_bundle_with_criteria(bundle, order = :asc)
  sorted_entries = bundle.entry.sort do |entry1, entry2|
    value1 = yield(entry1.resource)
    value2 = yield(entry2.resource)
    if value2.nil?
      -1
    elsif value1.nil?
      1
    else
      order == :asc ? value1 <=> value2 : value2 <=> value1
    end
  end
  bundle.entry = sorted_entries
  bundle
end

#sort_lab_entries(entries) ⇒ Object (protected)

Sort the FHIR::Bundle entries for lab & test results in reverse chronological order. Different entry types use different date fields for sorting.

Parameters:

  • entries

    a list of FHIR objects



353
354
355
356
357
358
359
360
361
362
363
364
# File 'lib/medical_records/client.rb', line 353

def sort_lab_entries(entries)
  entries.sort_by! do |entry|
    case entry
    when FHIR::DiagnosticReport
      -entry.effectiveDateTime.to_i
    when FHIR::DocumentReference
      -entry.date.to_i
    else
      0
    end
  end
end