Skip to content

Commit

Permalink
Implement StructureMap-based extraction (#566)
Browse files Browse the repository at this point in the history
  • Loading branch information
ekigamba committed Jul 21, 2021
1 parent fbe606f commit bac35db
Show file tree
Hide file tree
Showing 13 changed files with 853 additions and 29 deletions.
5 changes: 5 additions & 0 deletions buildSrc/src/main/kotlin/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ object Dependencies {
}

const val androidJunitRunner = "androidx.test.runner.AndroidJUnitRunner"
const val apacheCommonsCompress =
"org.apache.commons:commons-compress:${Versions.apacheCommonsCompress}"
const val apacheCommonsIo = "commons-io:commons-io:${Versions.apacheCommonsIo}"
const val junit = "junit:junit:${Versions.junit}"
const val mockitoKotlin = "org.mockito.kotlin:mockito-kotlin:${Versions.mockitoKotlin}"
const val robolectric = "org.robolectric:robolectric:${Versions.robolectric}"
Expand Down Expand Up @@ -123,6 +126,8 @@ object Dependencies {
const val stdlib = "1.4.31"
}

const val apacheCommonsCompress = "1.20"
const val apacheCommonsIo = "2.10.0"
const val desugarJdkLibs = "1.0.9"
const val guava = "28.2-android"
const val hapiFhir = "5.4.0"
Expand Down
2 changes: 2 additions & 0 deletions datacapture/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ dependencies {

implementation(Dependencies.Androidx.appCompat)
implementation(Dependencies.Androidx.fragmentKtx)
implementation(Dependencies.apacheCommonsCompress)
implementation(Dependencies.apacheCommonsIo)
implementation(Dependencies.HapiFhir.validation) {
exclude(module = "commons-logging")
exclude(module = "httpclient")
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.android.fhir.datacapture

import org.hl7.fhir.r4.model.CanonicalType
import org.hl7.fhir.r4.model.Questionnaire

/**
* The StructureMap url in the
* [target structure-map extension](http://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-targetStructureMap.html)
* s.
*/
val Questionnaire.targetStructureMap: String?
get() {
val extensionValue =
this.extension.singleOrNull { it.url == TARGET_STRUCTURE_MAP }?.value ?: return null
return if (extensionValue is CanonicalType) extensionValue.valueAsString else null
}

/**
* See
* [Extension: target structure map](http://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-targetStructureMap.html)
* .
*/
private const val TARGET_STRUCTURE_MAP: String =
"http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-targetStructureMap"
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.android.fhir.datacapture.mapping

/** Thrown to indicate that the FHIR core package could not be initialized successfully */
class NpmPackageInitializationError : Exception {

constructor(cause: Throwable?) : super(cause)

constructor(message: String, cause: Throwable?) : super(message, cause)

constructor(message: String) : super(message)
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,20 @@

package com.google.android.fhir.datacapture.mapping

import android.content.Context
import ca.uhn.fhir.context.FhirContext
import ca.uhn.fhir.context.support.DefaultProfileValidationSupport
import com.google.android.fhir.datacapture.createQuestionnaireResponseItem
import com.google.android.fhir.datacapture.targetStructureMap
import com.google.android.fhir.datacapture.utilities.SimpleWorkerContextProvider
import java.lang.reflect.Field
import java.lang.reflect.Method
import java.lang.reflect.ParameterizedType
import java.util.Locale
import org.hl7.fhir.r4.hapi.ctx.HapiWorkerContext
import org.hl7.fhir.r4.model.Base
import org.hl7.fhir.r4.model.BooleanType
import org.hl7.fhir.r4.model.Bundle
import org.hl7.fhir.r4.model.CodeableConcept
import org.hl7.fhir.r4.model.Coding
import org.hl7.fhir.r4.model.DateTimeType
Expand All @@ -34,14 +38,17 @@ import org.hl7.fhir.r4.model.DecimalType
import org.hl7.fhir.r4.model.Expression
import org.hl7.fhir.r4.model.IdType
import org.hl7.fhir.r4.model.IntegerType
import org.hl7.fhir.r4.model.Parameters
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.Resource
import org.hl7.fhir.r4.model.StringType
import org.hl7.fhir.r4.model.StructureMap
import org.hl7.fhir.r4.model.TimeType
import org.hl7.fhir.r4.model.Type
import org.hl7.fhir.r4.model.UrlType
import org.hl7.fhir.r4.utils.FHIRPathEngine
import org.hl7.fhir.r4.utils.StructureMapUtilities

/**
* Maps [QuestionnaireResponse] s to FHIR resources and vice versa.
Expand All @@ -62,15 +69,76 @@ import org.hl7.fhir.r4.utils.FHIRPathEngine
object ResourceMapper {

/**
* Extract a FHIR resource from the `questionnaire` and `questionnaireResponse`.
* Extract a FHIR resource from the [questionnaire] and [questionnaireResponse].
*
* This method assumes there is only one FHIR resource to be extracted from the given
* `questionnaire` and `questionnaireResponse`.
* This method supports both Definition-based and StructureMap-based extraction.
*
* StructureMap-based extraction will be invoked if the [Questionnaire] declares a
* targetStructureMap extension otherwise Definition-based extraction is used. StructureMap-based
* extraction will fail and an empty [Bundle] will be returned if the [structureMapProvider] is
* not passed.
*
* @return [Bundle] containing the extracted [Resource]s or empty Bundle if the extraction fails.
* An exception might also be thrown in a few cases
*/
fun extract(
questionnaire: Questionnaire,
questionnaireResponse: QuestionnaireResponse,
structureMapProvider: ((String) -> StructureMap?)? = null,
context: Context? = null
): Bundle {
return if (questionnaire.targetStructureMap == null)
extractByDefinitions(questionnaire, questionnaireResponse)
else extractByStructureMap(questionnaire, questionnaireResponse, structureMapProvider, context)
}

/**
* Extracts a FHIR resource from the [questionnaire] and [questionnaireResponse] using the
* definition-based extraction methodology.
*
* It currently only supports extracting a single resource. It returns a [Bundle] with the
* extracted resource. If the process completely fails, an error is thrown or a [Bundle]
* containing empty [Resource] is returned
*/
fun extract(questionnaire: Questionnaire, questionnaireResponse: QuestionnaireResponse): Base {
private fun extractByDefinitions(
questionnaire: Questionnaire,
questionnaireResponse: QuestionnaireResponse
): Bundle {
val className = questionnaire.itemContextNameToExpressionMap.values.first()
return (Class.forName("org.hl7.fhir.r4.model.$className").newInstance() as Base).apply {
extractFields(questionnaire.item, questionnaireResponse.item)
val extractedResource =
(Class.forName("org.hl7.fhir.r4.model.$className").newInstance() as Resource).apply {
extractFields(questionnaire.item, questionnaireResponse.item)
}

return Bundle().apply {
type = Bundle.BundleType.TRANSACTION
addEntry(Bundle.BundleEntryComponent().apply { resource = extractedResource })
}
}

/**
* Extracts a FHIR resource from the [questionnaire], [questionnaireResponse] and
* [structureMapProvider] using the StructureMap-based extraction methodology.
*
* The [StructureMapProvider] implementation passed should fetch the referenced [StructureMap]
* either from persistence or a remote service. The [StructureMap] should strictly return a
* [Bundle], failure to this an exception will be thrown. If a [StructureMapProvider] is not
* passed, an empty [Bundle] object is returned
*/
private fun extractByStructureMap(
questionnaire: Questionnaire,
questionnaireResponse: QuestionnaireResponse,
structureMapProvider: ((String) -> StructureMap?)?,
context: Context?
): Bundle {
if (structureMapProvider == null || context == null) return Bundle()
val structureMap = structureMapProvider(questionnaire.targetStructureMap!!) ?: return Bundle()
val simpleWorkerContext = SimpleWorkerContextProvider.loadSimpleWorkerContext(context)
simpleWorkerContext.setExpansionProfile(Parameters())

return Bundle().apply {
StructureMapUtilities(simpleWorkerContext)
.transform(simpleWorkerContext, questionnaireResponse, structureMap, this)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.android.fhir.datacapture.utilities

import android.content.Context
import android.os.Environment
import android.util.Log
import com.google.android.fhir.datacapture.mapping.NpmPackageInitializationError
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import org.apache.commons.compress.utils.IOUtils
import org.hl7.fhir.utilities.npm.NpmPackage

/**
* Manages extracting the fhir core package into app storage and loading it into memory. Extracting
* the fhir core package should preferably be done after installation since it is a long running
* operation that takes 1-2 minutes. Loading the package into memory also takes considerable time
* (20 seconds to 1 minute) and should be preferably done at application start.
*
* However, both operations will be done automatically where required during StructureMap-based
* extraction.
*/
object NpmPackageProvider {

private const val FHIR_R4_CORE_PACKAGE_FILENAME = "packages.fhir.org-hl7.fhir.r4.core-4.0.1.tgz"

/**
*
* NpmPackage containing all the [org.hl7.fhir.r4.model.StructureDefinition]s takes around 20
* seconds to load. Therefore, reloading for each extraction is not desirable. This should happen
* once and cache the variable throughout the app's lifecycle.
*
* Call [loadNpmPackage] to load it. The method handles skips the operation if it's already
* loaded.
*/
private lateinit var npmPackage: NpmPackage

/**
* Decompresses the hl7.fhir.r4.core archived package into app storage and loads it into memory.
* It loads the package into [npmPackage]. The method skips any unnecessary operations. This
* method can be called during initial app installation and run in the background so as to reduce
* the time it takes for the whole process.
*
* The whole process can take 1-2 minutes on a clean installation.
*/
fun loadNpmPackage(context: Context): NpmPackage {
setupNpmPackage(context)

if (!this::npmPackage.isInitialized) {
npmPackage = NpmPackage.fromFolder(getLocalFhirCorePackageDirectory(context))
}

return npmPackage
}

/**
* Decompresses the hl7.fhir.r4.core archived package into app storage.
*
* The whole process can take 1-2 minutes.
*
* @Throws NpmPackageInitializationError
*/
private fun setupNpmPackage(context: Context) {
val outDir = getLocalFhirCorePackageDirectory(context)

if (File(outDir + "/package/package.json").exists()) {
return
}
// Create any missing folders
File(outDir).mkdirs()

// Copy the tgz package to private app storage
var inputStream: InputStream? = null
var outputStream: FileOutputStream? = null
try {
inputStream = context.assets.open(FHIR_R4_CORE_PACKAGE_FILENAME)
outputStream =
FileOutputStream(
File(getLocalNpmPackagesDirectory(context) + FHIR_R4_CORE_PACKAGE_FILENAME)
)

IOUtils.copy(inputStream, outputStream)
} catch (e: IOException) {
// Delete the folders
try {
val packageDirectory = File(outDir)
if (packageDirectory.exists()) {
packageDirectory.delete()
}
} catch (securityException: SecurityException) {
Log.e(NpmPackageProvider.javaClass.name, securityException.stackTraceToString())
}

throw NpmPackageInitializationError(
"Could not copy archived package [$FHIR_R4_CORE_PACKAGE_FILENAME] to app private storage",
e
)
} finally {
IOUtils.closeQuietly(inputStream)
IOUtils.closeQuietly(outputStream)
}

// decompress the .tgz package
TarGzipUtility.decompress(
getLocalNpmPackagesDirectory(context) + FHIR_R4_CORE_PACKAGE_FILENAME,
File(outDir)
)
}

/** Generates the path to the local npm packages directory. */
private fun getLocalNpmPackagesDirectory(context: Context): String =
Environment.getDataDirectory().getAbsolutePath() +
"/data/${context.applicationContext.packageName}/npm_packages/"

/** Generates the path to the local hl7.fhir.r4.core package. */
private fun getLocalFhirCorePackageDirectory(context: Context): String {
return getLocalNpmPackagesDirectory(context) + "hl7.fhir.r4.core#4.0.1"
}
}
Loading

0 comments on commit bac35db

Please sign in to comment.