Skip to content

Commit

Permalink
LaunchContext for initialExpression (google#2025)
Browse files Browse the repository at this point in the history
* Use launchContext for initialExpression

* Test ResourceMapper

* Fix test

* Fix launchContexts for demo app when editing patient

* spotlessApply

* WIP

* Revert "WIP"

This reverts commit d2d53a2.

* spotlessApply

* Fix test

* Refactor validateLaunchContextExtension

* Remove QuestionnaireLaunchContextSet enum class

* Rename vars and functions

* Add code comment for MoreResourceTypes.kt

* Unit testing

* Fix post-merge-conflict

* spotlessApply

* Address review

* spotlessApply

* Revert un-intended changes

* Fix failing checks

* Remove check of must contain 2 sub-extensions

* Update Kdoc

* Revert

* validate launch context when using populate public API
  • Loading branch information
FikriMilano committed Oct 13, 2023
1 parent 633b482 commit caf4c14
Show file tree
Hide file tree
Showing 10 changed files with 433 additions and 234 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,11 @@ class DemoQuestionnaireFragment : Fragment() {
R.id.container,
QuestionnaireFragment.builder()
.setQuestionnaire(questionnaireJsonString)
.setQuestionnaireLaunchContexts(
.setQuestionnaireLaunchContextMap(
FhirContext.forR4Cached()
.newJsonParser()
.encodeResourceToString(Patient().apply { id = "P1" })
.let { listOf(it) },
.let { mapOf("patient" to it) },
)
.build(),
QUESTIONNAIRE_FRAGMENT_TAG,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -352,10 +352,10 @@ class QuestionnaireFragment : Fragment() {
* user, etc. is "in context" at the time the questionnaire response is being completed:
* https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-launchContext.html
*
* @param launchContexts list of serialized resources
* @param launchContextMap map of launchContext name and serialized resources
*/
fun setQuestionnaireLaunchContexts(launchContexts: List<String>) = apply {
args.add(EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRINGS to launchContexts)
fun setQuestionnaireLaunchContextMap(launchContextMap: Map<String, String>) = apply {
args.add(EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP to launchContextMap)
}

/**
Expand Down Expand Up @@ -454,9 +454,10 @@ class QuestionnaireFragment : Fragment() {
*/
internal const val EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING = "questionnaire-response"

/** A list of JSON encoded strings extra for each questionnaire context. */
internal const val EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRINGS =
"questionnaire-launch-contexts"
/**
* A map of launchContext name and JSON encoded strings extra for each questionnaire context.
*/
internal const val EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP = "questionnaire-launch-contexts"

/**
* A [URI][android.net.Uri] extra for streaming a JSON encoded questionnaire response.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import com.google.android.fhir.datacapture.extensions.allItems
import com.google.android.fhir.datacapture.extensions.cqfExpression
import com.google.android.fhir.datacapture.extensions.createQuestionnaireResponseItem
import com.google.android.fhir.datacapture.extensions.entryMode
import com.google.android.fhir.datacapture.extensions.filterByCodeInNameExtension
import com.google.android.fhir.datacapture.extensions.flattened
import com.google.android.fhir.datacapture.extensions.hasDifferentAnswerSet
import com.google.android.fhir.datacapture.extensions.isDisplayItem
Expand Down Expand Up @@ -166,15 +167,16 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat

init {
questionnaireLaunchContextMap =
if (state.contains(QuestionnaireFragment.EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRINGS)) {
if (state.contains(QuestionnaireFragment.EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP)) {

val launchContextJsonStrings: List<String> =
state[QuestionnaireFragment.EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRINGS]!!
val launchContextMapString: Map<String, String> =
state[QuestionnaireFragment.EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP]!!

val launchContexts = launchContextJsonStrings.map { parser.parseResource(it) as Resource }
val launchContextMapResource =
launchContextMapString.mapValues { parser.parseResource(it.value) as Resource }
questionnaire.questionnaireLaunchContexts?.let { launchContextExtensions ->
validateLaunchContextExtensions(launchContextExtensions)
launchContexts.associateBy { it.resourceType.name.lowercase() }
filterByCodeInNameExtension(launchContextMapResource, launchContextExtensions)
}
} else {
null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@

package com.google.android.fhir.datacapture.extensions

import org.hl7.fhir.exceptions.FHIRException
import org.hl7.fhir.r4.model.CanonicalType
import org.hl7.fhir.r4.model.CodeType
import org.hl7.fhir.r4.model.Coding
import org.hl7.fhir.r4.model.Expression
import org.hl7.fhir.r4.model.Extension
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.Resource
import org.hl7.fhir.r4.model.ResourceType

/**
* The StructureMap url in the
Expand Down Expand Up @@ -64,73 +67,54 @@ internal fun Questionnaire.findVariableExpression(variableName: String): Express
*/
internal fun validateLaunchContextExtensions(launchContextExtensions: List<Extension>) =
launchContextExtensions.forEach { launchExtension ->
validateLaunchContextExtension(
Extension().apply {
addExtension(launchExtension.extension.firstOrNull { it.url == "name" })
addExtension(launchExtension.extension.firstOrNull { it.url == "type" })
},
)
validateLaunchContextExtension(launchExtension)
}

/**
* Checks that the extension:name extension exists and its value contains a valid code from
* [QuestionnaireLaunchContextSet]
* Verifies the existence of extension:name and extension:type with valid name system and type
* values.
*/
private fun validateLaunchContextExtension(launchExtension: Extension) {
check(launchExtension.extension.size == 2) {
"The extension:name or extension:type extension is missing in $EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT"
}
val nameCoding =
launchExtension.getExtensionByUrl("name")?.value as? Coding
?: error(
"The extension:name is missing or is not of type Coding in $EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT",
)

val isValidExtension =
QuestionnaireLaunchContextSet.values().any {
launchExtension.equalsDeep(
Extension().apply {
addExtension(
Extension().apply {
url = "name"
setValue(
Coding().apply {
code = it.code
display = it.display
system = it.system
},
)
},
)
addExtension(
Extension().apply {
url = "type"
setValue(CodeType().setValue(it.resourceType))
},
)
},
val typeCodeType =
launchExtension.getExtensionByUrl("type")?.value as? CodeType
?: error(
"The extension:type is missing or is not of type CodeType in $EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT",
)

val isValidResourceType =
try {
ResourceType.fromCode(typeCodeType.value) != null
} catch (exception: FHIRException) {
false
}
if (!isValidExtension) {

if (nameCoding.system != EXTENSION_LAUNCH_CONTEXT || !isValidResourceType) {
error(
"The extension:name extension and/or extension:type extension do not follow the format " +
"specified in $EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT",
"The extension:name and/or extension:type do not follow the format specified in $EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT",
)
}
}

/**
* The set of supported launch contexts, as per: http:https://hl7.org/fhir/uv/sdc/ValueSet/launchContext
* Filters the provided launch contexts by matching the keys with the `code` values found in the
* "name" extensions.
*/
private enum class QuestionnaireLaunchContextSet(
val code: String,
val display: String,
val system: String,
val resourceType: String,
) {
PATIENT("patient", "Patient", EXTENSION_LAUNCH_CONTEXT, "Patient"),
ENCOUNTER("encounter", "Encounter", EXTENSION_LAUNCH_CONTEXT, "Encounter"),
LOCATION("location", "Location", EXTENSION_LAUNCH_CONTEXT, "Location"),
USER_AS_PATIENT("user", "User", EXTENSION_LAUNCH_CONTEXT, "Patient"),
USER_AS_PRACTITIONER("user", "User", EXTENSION_LAUNCH_CONTEXT, "Practitioner"),
USER_AS_PRACTITIONER_ROLE("user", "User", EXTENSION_LAUNCH_CONTEXT, "PractitionerRole"),
USER_AS_RELATED_PERSON("user", "User", EXTENSION_LAUNCH_CONTEXT, "RelatedPerson"),
STUDY("study", "ResearchStudy", EXTENSION_LAUNCH_CONTEXT, "ResearchStudy"),
internal fun filterByCodeInNameExtension(
launchContexts: Map<String, Resource>,
launchContextExtensions: List<Extension>,
): Map<String, Resource> {
val nameCodes =
launchContextExtensions
.mapNotNull { extension -> (extension.getExtensionByUrl("name").value as? Coding)?.code }
.toSet()

return launchContexts.filterKeys { nameCodes.contains(it) }
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ package com.google.android.fhir.datacapture.mapping

import com.google.android.fhir.datacapture.DataCapture
import com.google.android.fhir.datacapture.extensions.createQuestionnaireResponseItem
import com.google.android.fhir.datacapture.extensions.filterByCodeInNameExtension
import com.google.android.fhir.datacapture.extensions.initialExpression
import com.google.android.fhir.datacapture.extensions.logicalId
import com.google.android.fhir.datacapture.extensions.questionnaireLaunchContexts
import com.google.android.fhir.datacapture.extensions.targetStructureMap
import com.google.android.fhir.datacapture.extensions.toCodeType
import com.google.android.fhir.datacapture.extensions.toCoding
import com.google.android.fhir.datacapture.extensions.toIdType
import com.google.android.fhir.datacapture.extensions.toUriType
import com.google.android.fhir.datacapture.extensions.validateLaunchContextExtensions
import com.google.android.fhir.datacapture.fhirpath.fhirPathEngine
import java.lang.reflect.Field
import java.lang.reflect.Method
Expand Down Expand Up @@ -214,41 +217,42 @@ object ResourceMapper {
* Performs
* [Expression-based population](http:https://build.fhir.org/ig/HL7/sdc/populate.html#expression-based-population)
* and returns a [QuestionnaireResponse] for the [questionnaire] that is populated from the
* [resources].
* [launchContexts].
*/
suspend fun populate(
questionnaire: Questionnaire,
vararg resources: Resource,
launchContexts: Map<String, Resource>,
): QuestionnaireResponse {
populateInitialValues(questionnaire.item, *resources)
validateLaunchContextExtensions(questionnaire.questionnaireLaunchContexts ?: listOf())
val filteredLaunchContexts =
filterByCodeInNameExtension(
launchContexts,
questionnaire.questionnaireLaunchContexts ?: listOf(),
)
populateInitialValues(questionnaire.item, filteredLaunchContexts)
return QuestionnaireResponse().apply {
item = questionnaire.item.map { it.createQuestionnaireResponseItem() }
}
}

private suspend fun populateInitialValues(
questionnaireItems: List<Questionnaire.QuestionnaireItemComponent>,
vararg resources: Resource,
launchContexts: Map<String, Resource>,
) {
questionnaireItems.forEach { populateInitialValue(it, *resources) }
questionnaireItems.forEach { populateInitialValue(it, launchContexts) }
}

private suspend fun populateInitialValue(
questionnaireItem: Questionnaire.QuestionnaireItemComponent,
vararg resources: Resource,
launchContexts: Map<String, Resource>,
) {
check(questionnaireItem.initial.isEmpty() || questionnaireItem.initialExpression == null) {
"QuestionnaireItem item is not allowed to have both initial.value and initial expression. See rule at http:https://build.fhir.org/ig/HL7/sdc/expressions.html#initialExpression."
}

questionnaireItem.initialExpression
?.let {
fhirPathEngine
.evaluate(
selectPopulationContext(resources.asList(), it),
it.expression.removePrefix("%"),
)
.singleOrNull()
fhirPathEngine.evaluate(launchContexts, null, null, null, it.expression).firstOrNull()
}
?.let {
// Set initial value for the questionnaire item. Questionnaire items should not have both
Expand All @@ -258,24 +262,7 @@ object ResourceMapper {
mutableListOf(Questionnaire.QuestionnaireItemInitialComponent().setValue(value))
}

populateInitialValues(questionnaireItem.item, *resources)
}

/**
* Returns the population context for the questionnaire/group.
*
* The resource of the same type as the expected type of the initial expression will be selected
* first. Otherwise, the first resource in the list will be selected.
*
* TODO: rewrite this using the launch context and population context.
*/
private fun selectPopulationContext(
resources: List<Resource>,
initialExpression: Expression,
): Resource? {
val resourceType = initialExpression.expression.substringBefore(".").removePrefix("%")
return resources.singleOrNull { it.resourceType.name.lowercase() == resourceType.lowercase() }
?: resources.firstOrNull()
populateInitialValues(questionnaireItem.item, launchContexts)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import ca.uhn.fhir.context.FhirContext
import ca.uhn.fhir.parser.IParser
import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_ENABLE_REVIEW_PAGE
import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_JSON_STRING
import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRINGS
import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP
import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING
import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_READ_ONLY
import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_SHOW_CANCEL_BUTTON
Expand Down Expand Up @@ -4410,8 +4410,8 @@ class QuestionnaireViewModelTest {
}
state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
state.set(
EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRINGS,
listOf(printer.encodeResourceToString(patient)),
EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP,
mapOf("patient" to printer.encodeResourceToString(patient)),
)

val viewModel = QuestionnaireViewModel(context, state)
Expand Down
Loading

0 comments on commit caf4c14

Please sign in to comment.