Skip to content

Commit

Permalink
Merge branch 'master' into master-release
Browse files Browse the repository at this point in the history
  • Loading branch information
ndegwamartin committed Jun 15, 2023
2 parents ffa535a + 5e8698f commit fc9a379
Show file tree
Hide file tree
Showing 32 changed files with 1,551 additions and 223 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,16 @@ class QuestionnaireFragment : Fragment() {
args.add(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI to questionnaireResponseUri)
}

fun setQuestionnaireLaunchContext(questionnaireLaunchContext: String) = apply {
args.add(EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRING to questionnaireLaunchContext)
/**
* The launch context allows information to be passed into questionnaire based on the context in
* which the questionnaire is being evaluated. For example, what patient, what encounter, what
* 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
*/
fun setQuestionnaireLaunchContexts(launchContexts: List<String>) = apply {
args.add(EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRINGS to launchContexts)
}

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

/** A JSON encoded string extra for questionnaire context. */
internal const val EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRING =
"questionnaire-launch-context"
/** A list of JSON encoded strings extra for each questionnaire context. */
internal const val EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRINGS =
"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 @@ -26,7 +26,6 @@ import ca.uhn.fhir.context.FhirContext
import ca.uhn.fhir.context.FhirVersionEnum
import ca.uhn.fhir.parser.IParser
import com.google.android.fhir.datacapture.enablement.EnablementEvaluator
import com.google.android.fhir.datacapture.extensions.EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT
import com.google.android.fhir.datacapture.extensions.EntryMode
import com.google.android.fhir.datacapture.extensions.addNestedItemsToAnswer
import com.google.android.fhir.datacapture.extensions.allItems
Expand All @@ -44,9 +43,10 @@ import com.google.android.fhir.datacapture.extensions.isPaginated
import com.google.android.fhir.datacapture.extensions.isXFhirQuery
import com.google.android.fhir.datacapture.extensions.localizedTextSpanned
import com.google.android.fhir.datacapture.extensions.packRepeatedGroups
import com.google.android.fhir.datacapture.extensions.questionnaireLaunchContexts
import com.google.android.fhir.datacapture.extensions.shouldHaveNestedItemsUnderAnswers
import com.google.android.fhir.datacapture.extensions.unpackRepeatedGroups
import com.google.android.fhir.datacapture.extensions.validateLaunchContext
import com.google.android.fhir.datacapture.extensions.validateLaunchContextExtensions
import com.google.android.fhir.datacapture.extensions.zipByLinkId
import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator
import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.detectExpressionCyclicDependency
Expand Down Expand Up @@ -160,25 +160,24 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat

/**
* The launch context allows information to be passed into questionnaire based on the context in
* which he questionnaire is being evaluated. For example, what patient, what encounter, what
* user, etc. is "in context" at the time the questionnaire response is being completed.
* Currently, we support at most one launch context.The supported launch contexts are defined in:
* which the questionnaire is being evaluated. For example, what patient, what encounter, what
* 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
*/
private val questionnaireLaunchContext: Resource?
private val questionnaireLaunchContextMap: Map<String, Resource>?

init {
questionnaireLaunchContext =
if (state.contains(QuestionnaireFragment.EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRING)) {
val questionnaireLaunchContextJson: String =
state[QuestionnaireFragment.EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRING]!!
questionnaire.extension
.firstOrNull { it.url == EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT }
?.let {
val resource = parser.parseResource(questionnaireLaunchContextJson) as Resource
validateLaunchContext(it, resource.resourceType.name)
resource
}
questionnaireLaunchContextMap =
if (state.contains(QuestionnaireFragment.EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRINGS)) {

val launchContextJsonStrings: List<String> =
state[QuestionnaireFragment.EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRINGS]!!

val launchContexts = launchContextJsonStrings.map { parser.parseResource(it) as Resource }
questionnaire.questionnaireLaunchContexts?.let { launchContextExtensions ->
validateLaunchContextExtensions(launchContextExtensions)
launchContexts.associateBy { it.resourceType.name.lowercase() }
}
} else {
null
}
Expand Down Expand Up @@ -584,7 +583,10 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
}

val xFhirExpressionString =
ExpressionEvaluator.createXFhirQueryFromExpression(expression, questionnaireLaunchContext)
ExpressionEvaluator.createXFhirQueryFromExpression(
expression,
questionnaireLaunchContextMap
)
xFhirQueryResolver!!.resolve(xFhirExpressionString)
} else if (expression.isFhirPath) {
fhirPathEngine.evaluate(questionnaireResponse, expression.expression)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.google.android.fhir.datacapture.extensions

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
Expand All @@ -38,6 +39,16 @@ internal val Questionnaire.variableExpressions: List<Expression>
get() =
this.extension.filter { it.url == EXTENSION_VARIABLE_URL }.map { it.castToExpression(it.value) }

/**
* A list of extensions that define the resources that provide context for form processing logic:
* https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-launchContext.html
*/
internal val Questionnaire.questionnaireLaunchContexts: List<Extension>?
get() =
this.extension
.filter { it.url == EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT }
.takeIf { it.isNotEmpty() }

/**
* Finds the specific variable name [String] at questionnaire [Questionnaire] level
*
Expand All @@ -48,38 +59,57 @@ internal fun Questionnaire.findVariableExpression(variableName: String): Express
variableExpressions.find { it.name == variableName }

/**
* Validates the questionnaire launch context extension, if it exists, and well formed, and
* validates if the resource type is applicable as a launch context.
* Validates each questionnaire launch context extension matches:
* https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-launchContext.html
*/
internal fun validateLaunchContext(extension: Extension, resourceType: String) {
val nameExtension =
extension.extension
.firstOrNull { it.url == "name" }
?.value.takeIf { type ->
type is Coding &&
QuestionnaireLaunchContextSet.values().any {
it.code == type.code && it.display == type.display && it.system == type.system
}
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" })
}

val typeExtension =
extension.extension
.firstOrNull { it.url == "type" }
?.takeIf { it.valueAsPrimitive.valueAsString == resourceType }

if (nameExtension == null) {
error(
"The value of the extension:name field in " +
"$EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT is not one of the ones defined in " +
"$EXTENSION_LAUNCH_CONTEXT."
)
}

if (typeExtension == null) {
/**
* Checks that the extension:name extension exists and its value contains a valid code from
* [QuestionnaireLaunchContextSet]
*/
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 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))
}
)
}
)
}
if (!isValidExtension) {
error(
"The resource type set in the extension:type field in " +
"$EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT does not match the resource type of the " +
"context passed in: $resourceType."
"The extension:name extension and/or extension:type extension do not follow the format " +
"specified in $EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT"
)
}
}
Expand All @@ -91,12 +121,16 @@ private enum class QuestionnaireLaunchContextSet(
val code: String,
val display: String,
val system: String,
val resourceType: String,
) {
PATIENT("patient", "Patient", EXTENSION_LAUNCH_CONTEXT),
ENCOUNTER("encounter", "Encounter", EXTENSION_LAUNCH_CONTEXT),
LOCATION("location", "Location", EXTENSION_LAUNCH_CONTEXT),
USER("user", "User", EXTENSION_LAUNCH_CONTEXT),
STUDY("study", "ResearchStudy", EXTENSION_LAUNCH_CONTEXT),
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"),
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,16 +282,16 @@ object ExpressionEvaluator {
* Creates an x-fhir-query string for evaluation
*
* @param expression x-fhir-query expression
* @param launchContext if passed, the launch context to evaluate the expression against
* @param launchContextMap if passed, the launch context to evaluate the expression against
*/
internal fun createXFhirQueryFromExpression(
expression: Expression,
launchContext: Resource?
launchContextMap: Map<String, Resource>?
): String {
if (launchContext == null) {
if (launchContextMap == null) {
return expression.expression
}
return evaluateXFhirEnhancement(expression, launchContext).fold(expression.expression) {
return evaluateXFhirEnhancement(expression, launchContextMap).fold(expression.expression) {
acc: String,
pair: Pair<String, String> ->
acc.replace(pair.first, pair.second)
Expand All @@ -305,11 +305,11 @@ object ExpressionEvaluator {
*
* @param expression x-fhir-query expression containing a FHIRpath, e.g.
* Practitioner?active=true&{{Practitioner.name.family}}
* @param resource the launch context to evaluate the expression against
* @param launchContextMap the launch context to evaluate the expression against
*/
private fun evaluateXFhirEnhancement(
expression: Expression,
resource: Resource
launchContextMap: Map<String, Resource>
): Sequence<Pair<String, String>> =
xFhirQueryEnhancementRegex
.findAll(expression.expression)
Expand All @@ -318,12 +318,15 @@ object ExpressionEvaluator {
// TODO(omarismail94): See if FHIRPathEngine.check() can be used to distinguish invalid
// expression vs an expression that is valid, but does not return one resource only.
val expressionNode = fhirPathEngine.parse(fhirPath)
val resourceType =
expressionNode.constant?.primitiveValue()?.substring(1)
?: expressionNode.name?.lowercase()
val evaluatedResult =
fhirPathEngine.evaluateToString(
mapOf(resource.resourceType.name.lowercase() to resource),
launchContextMap,
null,
null,
resource,
launchContextMap[resourceType],
expressionNode
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,8 @@ import org.hl7.fhir.r4.utils.FHIRPathEngine
* Resolves constants defined in the fhir path expressions beyond those defined in the specification
*/
internal object FHIRPathEngineHostServices : FHIRPathEngine.IEvaluationContext {
override fun resolveConstant(appContext: Any?, name: String?, beforeContext: Boolean): Base? {
return if (appContext is Map<*, *> && appContext.containsKey(name)) appContext[name] as Base
else null
}
override fun resolveConstant(appContext: Any?, name: String?, beforeContext: Boolean): Base? =
(appContext as? Map<*, *>)?.get(name) as? Base

override fun resolveConstantType(appContext: Any?, name: String?): TypeDetails {
throw UnsupportedOperationException()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import com.google.android.fhir.FhirEngine
import com.google.android.fhir.FhirEngineProvider
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_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_RESPONSE_JSON_STRING
import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_READ_ONLY
import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_SHOW_REVIEW_PAGE_FIRST
Expand Down Expand Up @@ -86,7 +86,6 @@ import org.hl7.fhir.r4.model.Practitioner
import org.hl7.fhir.r4.model.Quantity
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent
import org.hl7.fhir.r4.model.StringType
import org.hl7.fhir.r4.model.ValueSet
import org.hl7.fhir.r4.utils.ToolingExtensions
Expand Down Expand Up @@ -3921,13 +3920,7 @@ class QuestionnaireViewModelTest {
)

val patientId = "123"
val patient =
Patient().apply {
id = patientId
active = true
gender = Enumerations.AdministrativeGender.MALE
addName(HumanName().apply { this.family = "Johnny" })
}
val patient = Patient().apply { id = patientId }

val questionnaire =
Questionnaire().apply {
Expand Down Expand Up @@ -3968,8 +3961,8 @@ class QuestionnaireViewModelTest {
}
state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
state.set(
EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRING,
printer.encodeResourceToString(patient)
EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRINGS,
listOf(printer.encodeResourceToString(patient))
)

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

0 comments on commit fc9a379

Please sign in to comment.