• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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