1 /* 2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.server.healthconnect.phr.validations; 18 19 import static android.health.connect.datatypes.FhirResource.FHIR_RESOURCE_TYPE_ALLERGY_INTOLERANCE; 20 import static android.health.connect.datatypes.FhirResource.FHIR_RESOURCE_TYPE_CONDITION; 21 import static android.health.connect.datatypes.FhirResource.FHIR_RESOURCE_TYPE_ENCOUNTER; 22 import static android.health.connect.datatypes.FhirResource.FHIR_RESOURCE_TYPE_IMMUNIZATION; 23 import static android.health.connect.datatypes.FhirResource.FHIR_RESOURCE_TYPE_LOCATION; 24 import static android.health.connect.datatypes.FhirResource.FHIR_RESOURCE_TYPE_MEDICATION; 25 import static android.health.connect.datatypes.FhirResource.FHIR_RESOURCE_TYPE_MEDICATION_REQUEST; 26 import static android.health.connect.datatypes.FhirResource.FHIR_RESOURCE_TYPE_MEDICATION_STATEMENT; 27 import static android.health.connect.datatypes.FhirResource.FHIR_RESOURCE_TYPE_OBSERVATION; 28 import static android.health.connect.datatypes.FhirResource.FHIR_RESOURCE_TYPE_ORGANIZATION; 29 import static android.health.connect.datatypes.FhirResource.FHIR_RESOURCE_TYPE_PATIENT; 30 import static android.health.connect.datatypes.FhirResource.FHIR_RESOURCE_TYPE_PRACTITIONER; 31 import static android.health.connect.datatypes.FhirResource.FHIR_RESOURCE_TYPE_PRACTITIONER_ROLE; 32 import static android.health.connect.datatypes.FhirResource.FHIR_RESOURCE_TYPE_PROCEDURE; 33 import static android.health.connect.datatypes.FhirResource.FhirResourceType; 34 import static android.health.connect.datatypes.MedicalResource.MEDICAL_RESOURCE_TYPE_ALLERGIES_INTOLERANCES; 35 import static android.health.connect.datatypes.MedicalResource.MEDICAL_RESOURCE_TYPE_CONDITIONS; 36 import static android.health.connect.datatypes.MedicalResource.MEDICAL_RESOURCE_TYPE_LABORATORY_RESULTS; 37 import static android.health.connect.datatypes.MedicalResource.MEDICAL_RESOURCE_TYPE_MEDICATIONS; 38 import static android.health.connect.datatypes.MedicalResource.MEDICAL_RESOURCE_TYPE_PERSONAL_DETAILS; 39 import static android.health.connect.datatypes.MedicalResource.MEDICAL_RESOURCE_TYPE_PRACTITIONER_DETAILS; 40 import static android.health.connect.datatypes.MedicalResource.MEDICAL_RESOURCE_TYPE_PREGNANCY; 41 import static android.health.connect.datatypes.MedicalResource.MEDICAL_RESOURCE_TYPE_PROCEDURES; 42 import static android.health.connect.datatypes.MedicalResource.MEDICAL_RESOURCE_TYPE_SOCIAL_HISTORY; 43 import static android.health.connect.datatypes.MedicalResource.MEDICAL_RESOURCE_TYPE_VACCINES; 44 import static android.health.connect.datatypes.MedicalResource.MEDICAL_RESOURCE_TYPE_VISITS; 45 import static android.health.connect.datatypes.MedicalResource.MEDICAL_RESOURCE_TYPE_VITAL_SIGNS; 46 import static android.health.connect.datatypes.MedicalResource.MedicalResourceType; 47 import static android.health.connect.internal.datatypes.utils.FhirResourceTypeStringToIntMapper.getFhirResourceTypeInt; 48 49 import android.annotation.Nullable; 50 import android.health.connect.UpsertMedicalResourceRequest; 51 import android.health.connect.datatypes.FhirVersion; 52 53 import com.android.healthfitness.flags.Flags; 54 import com.android.server.healthconnect.storage.request.UpsertMedicalResourceInternalRequest; 55 56 import org.json.JSONArray; 57 import org.json.JSONException; 58 import org.json.JSONObject; 59 60 import java.util.Collections; 61 import java.util.HashSet; 62 import java.util.Set; 63 64 /** 65 * Performs MedicalResource validation and extractions on an {@link UpsertMedicalResourceRequest}. 66 * 67 * @hide 68 */ 69 public class MedicalResourceValidator { 70 private static final String CONTAINED_FIELD = "contained"; 71 72 // For the values in these codes see 73 // https://build.fhir.org/ig/HL7/fhir-ips/StructureDefinition-Observation-pregnancy-status-uv-ips.html 74 private static final Set<String> PREGNANCY_LOINC_CODES = 75 Set.of( 76 "82810-3", "11636-8", "11637-6", "11638-4", "11639-2", "11640-0", "11612-9", 77 "11613-7", "11614-5", "33065-4", "11778-8", "11779-6", "11780-4"); 78 // Defined from IPS Artifacts (Alcohol and Tobacco): 79 // https://build.fhir.org/ig/HL7/fhir-ips/artifacts.html 80 // https://build.fhir.org/ig/HL7/fhir-ips/StructureDefinition-Observation-alcoholuse-uv-ips.html 81 // https://build.fhir.org/ig/HL7/fhir-ips/StructureDefinition-Observation-tobaccouse-uv-ips.html 82 private static final Set<String> SOCIAL_HISTORY_LOINC_CODES = Set.of("74013-4", "72166-2"); 83 // Defined from https://hl7.org/fhir/R5/observation-vitalsigns.html 84 private static final Set<String> VITAL_SIGNS_LOINC_CODES = 85 Set.of( 86 "85353-1", "9279-1", "8867-4", "2708-6", "8310-5", "8302-2", "9843-4", 87 "29463-7", "39156-5", "85354-9", "8480-6", "8462-4"); 88 // From http://terminology.hl7.org/CodeSystem/observation-category 89 private static final String OBSERVATION_CATEGORY_SOCIAL_HISTORY = "social-history"; 90 private static final String OBSERVATION_CATEGORY_VITAL_SIGNS = "vital-signs"; 91 private static final String OBSERVATION_CATEGORY_LABORATORY = "laboratory"; 92 93 private final String mFhirData; 94 private final FhirVersion mFhirVersion; 95 private final String mDataSourceId; 96 private @Nullable FhirResourceValidator mFhirResourceValidator; 97 98 /** Returns a validator for the provided {@link UpsertMedicalResourceRequest}. */ MedicalResourceValidator( UpsertMedicalResourceRequest request, @Nullable FhirResourceValidator fhirResourceValidator)99 public MedicalResourceValidator( 100 UpsertMedicalResourceRequest request, 101 @Nullable FhirResourceValidator fhirResourceValidator) { 102 mFhirData = request.getData(); 103 mFhirVersion = request.getFhirVersion(); 104 mDataSourceId = request.getDataSourceId(); 105 mFhirResourceValidator = fhirResourceValidator; 106 } 107 108 /** 109 * Validates the values provided in the {@link UpsertMedicalResourceRequest}. 110 * 111 * <p>It performs the following checks 112 * 113 * <ul> 114 * <li>The extracted FHIR resource id cannot be empty 115 * <li>Fhir version needs to be a supported version 116 * <li>The extracted FHIR resource type needs to be a supported type 117 * <li>The FHIR resource id cannot contain any "contained" resources 118 * <li>The resource needs to map to one of our permission categories 119 * </ul> 120 * 121 * <p>Returns a validated {@link UpsertMedicalResourceInternalRequest} 122 * 123 * @throws IllegalArgumentException if {@link UpsertMedicalResourceRequest#getData()} is invalid 124 * json, if the id field or resourceType field cannot be found or if any of the above checks 125 * fail. 126 */ validateAndCreateInternalRequest()127 public UpsertMedicalResourceInternalRequest validateAndCreateInternalRequest() 128 throws IllegalArgumentException { 129 JSONObject parsedFhirJsonObj = parseJsonResource(mFhirData); 130 String extractedFhirResourceId = extractResourceId(parsedFhirJsonObj); 131 String extractedFhirResourceTypeString = 132 extractResourceType(parsedFhirJsonObj, extractedFhirResourceId); 133 134 validateResourceId(extractedFhirResourceId); 135 validateFhirVersion(mFhirVersion, extractedFhirResourceId); 136 if (Flags.phrFhirStructuralValidation()) { 137 validateNoContainedResourcesPresent(parsedFhirJsonObj, extractedFhirResourceId); 138 } 139 140 @FhirResourceType 141 int fhirResourceTypeInt = 142 validateAndGetResourceType( 143 extractedFhirResourceTypeString, extractedFhirResourceId); 144 145 if (mFhirResourceValidator != null) { 146 mFhirResourceValidator.validateFhirResource( 147 parsedFhirJsonObj, fhirResourceTypeInt, mFhirVersion); 148 } 149 150 @MedicalResourceType 151 int medicalResourceTypeInt = 152 calculateMedicalResourceType( 153 fhirResourceTypeInt, 154 extractedFhirResourceTypeString, 155 extractedFhirResourceId, 156 parsedFhirJsonObj); 157 158 return new UpsertMedicalResourceInternalRequest() 159 .setMedicalResourceType(medicalResourceTypeInt) 160 .setFhirResourceId(extractedFhirResourceId) 161 .setFhirResourceType(fhirResourceTypeInt) 162 .setDataSourceId(mDataSourceId) 163 .setFhirVersion(mFhirVersion) 164 .setData(mFhirData); 165 } 166 parseJsonResource(String fhirData)167 private static JSONObject parseJsonResource(String fhirData) { 168 try { 169 return new JSONObject(fhirData); 170 } catch (JSONException e) { 171 throw new IllegalArgumentException("FHIR data is invalid json"); 172 } 173 } 174 extractResourceId(JSONObject fhirJsonObj)175 private static String extractResourceId(JSONObject fhirJsonObj) { 176 Object id; 177 try { 178 id = fhirJsonObj.get("id"); 179 } catch (JSONException e) { 180 throw new IllegalArgumentException("Resource is missing id field"); 181 } 182 183 // The FHIR spec expects this to be a string, so throw an error if this is not a json string 184 // to avoid cases where null leads to an id value "null" for example, if we were to use 185 // JSONObject.getString("id") instead. 186 if (!(id instanceof String)) { 187 throw new IllegalArgumentException("Resource id should be a string"); 188 } 189 190 return (String) id; 191 } 192 extractResourceType(JSONObject fhirJsonObj, String resourceId)193 private static String extractResourceType(JSONObject fhirJsonObj, String resourceId) { 194 try { 195 return fhirJsonObj.getString("resourceType"); 196 } catch (JSONException e) { 197 throw new IllegalArgumentException( 198 "Missing resourceType field for resource with id " + resourceId); 199 } 200 } 201 validateResourceId(String resourceId)202 private static void validateResourceId(String resourceId) { 203 if (resourceId.isEmpty()) { 204 throw new IllegalArgumentException("Resource id cannot be empty"); 205 } 206 } 207 validateFhirVersion(FhirVersion fhirVersion, String resourceId)208 private static void validateFhirVersion(FhirVersion fhirVersion, String resourceId) { 209 if (!fhirVersion.isSupportedFhirVersion()) { 210 throw new IllegalArgumentException( 211 "Unsupported FHIR version " 212 + fhirVersion 213 + " for resource with id " 214 + resourceId); 215 } 216 } 217 validateNoContainedResourcesPresent( JSONObject fhirJsonObject, String resourceId)218 private static void validateNoContainedResourcesPresent( 219 JSONObject fhirJsonObject, String resourceId) { 220 if (!fhirJsonObject.has(CONTAINED_FIELD)) { 221 return; 222 } 223 224 JSONArray contained; 225 try { 226 contained = fhirJsonObject.getJSONArray(CONTAINED_FIELD); 227 } catch (JSONException exception) { 228 throw new IllegalArgumentException( 229 "Contained resources are not supported. Found contained field for resource" 230 + " with id " 231 + resourceId); 232 } 233 234 if (contained.length() != 0) { 235 throw new IllegalArgumentException( 236 "Contained resources are not supported. Found contained resource for resource" 237 + " with id " 238 + resourceId); 239 } 240 } 241 242 /** 243 * Returns the corresponding {@code IntDef} {@link FhirResourceType} of the fhir resource. 244 * 245 * @throws IllegalArgumentException if the type is not supported. 246 */ 247 @FhirResourceType validateAndGetResourceType(String fhirResourceType, String fhirResourceId)248 private static int validateAndGetResourceType(String fhirResourceType, String fhirResourceId) { 249 int fhirResourceTypeInt; 250 try { 251 fhirResourceTypeInt = getFhirResourceTypeInt(fhirResourceType); 252 } catch (IllegalArgumentException e) { 253 throw new IllegalArgumentException( 254 "Unsupported FHIR resource type " 255 + fhirResourceType 256 + " for resource with id " 257 + fhirResourceId); 258 } 259 260 return fhirResourceTypeInt; 261 } 262 263 /** 264 * Returns the corresponding {@code IntDef} {@link MedicalResourceType} of the fhir resource. 265 * 266 * @throws IllegalArgumentException if the type can not be mapped. 267 */ 268 @MedicalResourceType calculateMedicalResourceType( int fhirResourceType, String fhirResourceTypeString, String fhirResourceId, JSONObject json)269 private static int calculateMedicalResourceType( 270 int fhirResourceType, 271 String fhirResourceTypeString, 272 String fhirResourceId, 273 JSONObject json) { 274 // TODO(b/342574702): add mapping logic for more FHIR resource types and improve error 275 // message. 276 switch (fhirResourceType) { 277 case FHIR_RESOURCE_TYPE_ALLERGY_INTOLERANCE: 278 return MEDICAL_RESOURCE_TYPE_ALLERGIES_INTOLERANCES; 279 case FHIR_RESOURCE_TYPE_CONDITION: 280 return MEDICAL_RESOURCE_TYPE_CONDITIONS; 281 case FHIR_RESOURCE_TYPE_ENCOUNTER, 282 FHIR_RESOURCE_TYPE_LOCATION, 283 FHIR_RESOURCE_TYPE_ORGANIZATION: 284 return MEDICAL_RESOURCE_TYPE_VISITS; 285 case FHIR_RESOURCE_TYPE_IMMUNIZATION: 286 return MEDICAL_RESOURCE_TYPE_VACCINES; 287 case FHIR_RESOURCE_TYPE_OBSERVATION: 288 Integer classification = classifyObservation(json); 289 if (classification != null) { 290 return classification; 291 } else { 292 break; 293 } 294 case FHIR_RESOURCE_TYPE_PATIENT: 295 return MEDICAL_RESOURCE_TYPE_PERSONAL_DETAILS; 296 case FHIR_RESOURCE_TYPE_PRACTITIONER, FHIR_RESOURCE_TYPE_PRACTITIONER_ROLE: 297 return MEDICAL_RESOURCE_TYPE_PRACTITIONER_DETAILS; 298 case FHIR_RESOURCE_TYPE_PROCEDURE: 299 return MEDICAL_RESOURCE_TYPE_PROCEDURES; 300 case FHIR_RESOURCE_TYPE_MEDICATION, 301 FHIR_RESOURCE_TYPE_MEDICATION_REQUEST, 302 FHIR_RESOURCE_TYPE_MEDICATION_STATEMENT: 303 return MEDICAL_RESOURCE_TYPE_MEDICATIONS; 304 default: 305 break; 306 } 307 throw new IllegalArgumentException( 308 "Resource with type " 309 + fhirResourceTypeString 310 + " and id " 311 + fhirResourceId 312 + " could not be mapped to a permissions category."); 313 } 314 315 @Nullable 316 @MedicalResourceType classifyObservation(JSONObject json)317 private static Integer classifyObservation(JSONObject json) { 318 /* 319 The priority order of categories to check is 320 - Pregnancy 321 - Social History 322 - Vital Signs 323 - Imaging 324 - Labs 325 326 Pregnancy is based on code alone. 327 Social History is based on code or category 328 Vital signs is based on code or category 329 Labs are based on category alone. 330 For now we only consider LOINC codes nad default FHIR categories. 331 */ 332 Set<String> loincCodes; 333 try { 334 JSONObject codeEntry = json.getJSONObject("code"); 335 loincCodes = getCodesOfType(codeEntry, "http://loinc.org"); 336 } catch (JSONException ex) { 337 loincCodes = Set.of(); 338 } 339 Set<String> categories = new HashSet<>(); 340 try { 341 JSONArray categoryList = json.getJSONArray("category"); 342 for (int i = 0; i < categoryList.length(); i++) { 343 categories.addAll( 344 getCodesOfType( 345 categoryList.getJSONObject(i), 346 "http://terminology.hl7.org/CodeSystem/observation-category")); 347 } 348 } catch (JSONException ex) { 349 // If an error is hit fetching category, assume no categories. 350 } 351 if (!Collections.disjoint(PREGNANCY_LOINC_CODES, loincCodes)) { 352 return MEDICAL_RESOURCE_TYPE_PREGNANCY; 353 } 354 if (!Collections.disjoint(SOCIAL_HISTORY_LOINC_CODES, loincCodes) 355 || categories.contains(OBSERVATION_CATEGORY_SOCIAL_HISTORY)) { // 356 return MEDICAL_RESOURCE_TYPE_SOCIAL_HISTORY; 357 } 358 if (!Collections.disjoint(VITAL_SIGNS_LOINC_CODES, loincCodes) 359 || categories.contains(OBSERVATION_CATEGORY_VITAL_SIGNS)) { // 360 return MEDICAL_RESOURCE_TYPE_VITAL_SIGNS; 361 } 362 if (categories.contains(OBSERVATION_CATEGORY_LABORATORY)) { // 363 return MEDICAL_RESOURCE_TYPE_LABORATORY_RESULTS; 364 } 365 return null; 366 } 367 getCodesOfType(JSONObject codeableConcept, String codingSystem)368 private static Set<String> getCodesOfType(JSONObject codeableConcept, String codingSystem) { 369 Set<String> codes = new HashSet<>(); 370 try { 371 JSONArray codings = codeableConcept.getJSONArray("coding"); 372 for (int i = 0; i < codings.length(); i++) { 373 JSONObject coding = codings.getJSONObject(i); 374 try { 375 String system = coding.getString("system"); 376 String code = coding.getString("code"); 377 if (codingSystem.equals(system)) { 378 codes.add(code); 379 } 380 } catch (JSONException ex) { 381 // On exception, carry on to try the next coding 382 } 383 } 384 } catch (JSONException ex) { 385 // Swallow any missing value issue 386 } 387 return codes; 388 } 389 } 390