Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import androidx.compose.ui.text.buildAnnotatedString
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import com.ionspin.kotlin.bignum.decimal.toBigDecimal
import dev.ohs.fhir.datacapture.QuestionnaireItemViewType
import dev.ohs.fhir.datacapture.extraction.template.EXTENSION_EXTRACT_ALLOCATE_ID_URL
import dev.ohs.fhir.datacapture.extraction.template.ItemExtractionContext
import dev.ohs.fhir.datacapture.extraction.template.TemplateExtractDefinition
import dev.ohs.fhir.datacapture.fhirpath.FhirPathService
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
*/
package dev.ohs.fhir.datacapture.extensions

import dev.ohs.fhir.datacapture.extraction.template.EXTENSION_EXTRACT_ALLOCATE_ID_URL
import dev.ohs.fhir.datacapture.extraction.template.TemplateExtractDefinition
import dev.ohs.fhir.model.r4.Bundle
import dev.ohs.fhir.model.r4.Expression
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ package dev.ohs.fhir.datacapture.extensions
import androidx.compose.ui.text.AnnotatedString
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import com.ionspin.kotlin.bignum.decimal.toBigDecimal
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid

internal fun String.toAnnotatedString(): AnnotatedString = AnnotatedString(this)

Expand All @@ -32,3 +34,15 @@ internal fun String.toBigDecimalOrNull(): BigDecimal? =

/** FHIRPath variables are referenced with `%`, while lookup maps store the bare identifier. */
internal fun String.normalizedVariableName(): String = removePrefix("%")

private val questionnaireResponseResourceReferenceRegex =
Regex("""(?<![A-Za-z0-9_'])%resource(?![A-Za-z0-9_'])""")

internal fun String.referencesQuestionnaireResponseResource(): Boolean =
questionnaireResponseResourceReferenceRegex.containsMatchIn(this)

internal const val EXTENSION_EXTRACT_ALLOCATE_ID_URL: String =
"http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-extractAllocateId"

@OptIn(ExperimentalUuidApi::class)
internal fun generateAllocatedFullUrl(): String = "urn:uuid:${Uuid.random()}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/*
* Copyright 2026 Open Health Stack Foundation
*
* 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 dev.ohs.fhir.datacapture.extraction.definition

import co.touchlab.kermit.Logger
import dev.ohs.fhir.datacapture.extensions.generateAllocatedFullUrl
import dev.ohs.fhir.model.r4.Bundle
import dev.ohs.fhir.model.r4.Questionnaire
import dev.ohs.fhir.model.r4.QuestionnaireResponse
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject

private val json = Json {
explicitNulls = false
encodeDefaults = false
}

/**
* Executes one `definitionExtract` scope and returns the `Bundle.entry` JSON for that scope.
*
* This is the spec's "initiate resource extraction" step. It creates the stub resource identified
* by the `definitionExtract.definition`, applies any root/item `definitionExtractValue` directives
* in scope, walks descendant items whose `Questionnaire.item.definition` canonical matches the
* scoped canonical, and then assembles request metadata such as `fullUrl`, `method`, and
* conditional headers.
*/
internal fun extractBundleEntryForDefinitionScope(
definitionExtract: DefinitionExtractConfig,
questionnaire: Questionnaire,
questionnaireResponse: QuestionnaireResponse,
scopeBase: Any,
scopeQuestionnaireItem: Questionnaire.Item?,
scopePairs: List<QuestionnaireItemResponsePair>,
inheritedAllocateIds: Map<String, String>,
resolveProfileResourceType: ((String) -> String?)?,
): JsonObject {
val resourceType =
inferResourceType(
definitionCanonical = definitionExtract.definition,
questionnaire = questionnaire,
scopeQuestionnaireItem = scopeQuestionnaireItem,
scopePairs = scopePairs,
resolveProfileResourceType = resolveProfileResourceType,
)
val rootDescriptor = resourceDescriptor(resourceType)
val resourceNode = MutableJsonObject(rootDescriptor)
val rootAnchor =
AnchorContext(path = emptyList(), node = resourceNode, descriptor = rootDescriptor)
val scopeCanonical = definitionExtract.definition

if (scopeQuestionnaireItem == null) {
applyDefinitionExtractValueDirectives(
sourceExtensions = questionnaire.extension,
scopeCanonical = scopeCanonical,
questionnaire = questionnaire,
questionnaireResponse = questionnaireResponse,
base = questionnaireResponse,
questionnaireItem = null,
responseItem = null,
allocateIds = inheritedAllocateIds,
rootAnchor = rootAnchor,
parentAnchor = rootAnchor,
directAnchor = null,
)
}

scopePairs.forEach {
applyQuestionnairePairToDefinitionScope(
pair = it,
questionnaire = questionnaire,
questionnaireResponse = questionnaireResponse,
scopeCanonical = scopeCanonical,
inheritedAllocateIds = inheritedAllocateIds,
rootAnchor = rootAnchor,
parentAnchor = rootAnchor,
)
}

addProfileIfNeeded(resourceNode, definitionExtract.definition, resourceType)

val resourceJson = resourceNode.toJsonObject(resourceType)
val resourceId = resourceNode.values["id"]?.toJsonElement()?.asString()
val requestMethod = if (resourceId.isNullOrBlank()) "POST" else "PUT"

return buildJsonObject {
val fullUrl =
definitionExtract.fullUrlExpression
?.let {
evaluateDefinitionExtractExpressionToString(
expression = it,
base = scopeBase,
questionnaire = questionnaire,
questionnaireResponse = questionnaireResponse,
questionnaireItem = scopeQuestionnaireItem,
responseItem = scopeBase as? QuestionnaireResponse.Item,
allocateIds = inheritedAllocateIds,
)
}
?.takeIf { it.isNotBlank() } ?: generateAllocatedFullUrl()
put("fullUrl", JsonPrimitive(fullUrl))

put("resource", resourceJson)

put(
"request",
buildRequestJson(
definitionExtract = definitionExtract,
questionnaire = questionnaire,
questionnaireResponse = questionnaireResponse,
scopeBase = scopeBase,
scopeQuestionnaireItem = scopeQuestionnaireItem,
resourceType = resourceType,
resourceId = resourceId,
requestMethod = requestMethod,
inheritedAllocateIds = inheritedAllocateIds,
),
)
}
}

/**
* Builds `Bundle.entry.request`, including any conditional headers whose expressions evaluate to a
* non-blank string.
*/
private fun buildRequestJson(
definitionExtract: DefinitionExtractConfig,
questionnaire: Questionnaire,
questionnaireResponse: QuestionnaireResponse,
scopeBase: Any,
scopeQuestionnaireItem: Questionnaire.Item?,
resourceType: String,
resourceId: String?,
requestMethod: String,
inheritedAllocateIds: Map<String, String>,
): JsonObject = buildJsonObject {
put("method", JsonPrimitive(requestMethod))
put(
"url",
JsonPrimitive(if (resourceId.isNullOrBlank()) resourceType else "$resourceType/$resourceId"),
)

fun putConditionalHeader(jsonKey: String, expression: String?) {
expression
?.let {
evaluateDefinitionExtractExpressionToString(
expression = it,
base = scopeBase,
questionnaire = questionnaire,
questionnaireResponse = questionnaireResponse,
questionnaireItem = scopeQuestionnaireItem,
responseItem = scopeBase as? QuestionnaireResponse.Item,
allocateIds = inheritedAllocateIds,
)
}
?.takeIf { it.isNotBlank() }
?.let { put(jsonKey, JsonPrimitive(it)) }
}

putConditionalHeader("ifNoneMatch", definitionExtract.ifNoneMatchExpression)
putConditionalHeader("ifModifiedSince", definitionExtract.ifModifiedSinceExpression)
putConditionalHeader("ifMatch", definitionExtract.ifMatchExpression)
putConditionalHeader("ifNoneExist", definitionExtract.ifNoneExistExpression)
}

/**
* Adds an extracted entry only if it fully materializes as a valid `Bundle.entry`.
*
* The SDC guide says extraction errors should be logged and processing should continue as though
* the failed query or directive produced no data. This helper keeps that behavior localized to one
* resource scope instead of failing the whole extraction transaction.
*/
internal inline fun materializeValidEntryOrNull(
scopeDescription: String,
block: () -> JsonObject,
): JsonObject? =
try {
val entryJson = block()
json.decodeFromJsonElement(Bundle.Entry.serializer(), entryJson)
entryJson
} catch (throwable: Throwable) {
Logger.w(
"Skipping definition-based extraction for $scopeDescription because it could not be fully materialized.",
throwable,
)
null
}

private fun JsonElement.asString(): String? =
(this as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright 2026 Open Health Stack Foundation
*
* 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 dev.ohs.fhir.datacapture.extraction.definition

import dev.ohs.fhir.model.r4.Resource
import kotlinx.serialization.descriptors.SerialDescriptor

internal object DefinitionExtractResourceRegistry {
Comment thread
Itskiprotich marked this conversation as resolved.
private val descriptorsByType: Map<String, SerialDescriptor> by lazy {
val resourceDescriptor = Resource.serializer().descriptor
val union = resourceDescriptor.getElementDescriptor(resourceDescriptor.getElementIndex("value"))
(0 until union.elementsCount).associate { index ->
union.getElementName(index) to union.getElementDescriptor(index)
}
}
internal val supportedResourceTypes: Set<String>
get() = descriptorsByType.keys

internal fun descriptorFor(resourceType: String): SerialDescriptor? =
descriptorsByType[resourceType]
}
Loading
Loading