diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/FhirEngineWrapper.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/FhirEngineWrapper.kt new file mode 100644 index 0000000000..2a4139c00d --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/FhirEngineWrapper.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine + +import com.google.android.fhir.FhirEngine +import com.google.android.fhir.LocalChange +import com.google.android.fhir.SearchResult +import com.google.android.fhir.db.LocalChangeResourceReference +import com.google.android.fhir.search.Search +import com.google.android.fhir.sync.ConflictResolver +import com.google.android.fhir.sync.upload.SyncUploadProgress +import com.google.android.fhir.sync.upload.UploadRequestResult +import com.google.android.fhir.sync.upload.UploadStrategy +import java.time.OffsetDateTime +import kotlinx.coroutines.flow.Flow +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType + +class FhirEngineWrapper(fhirEngineProducer: () -> FhirEngine) : FhirEngine { + + private val mFhirEngine by lazy { + return@lazy fhirEngineProducer.invoke() + } + + override suspend fun clearDatabase() = mFhirEngine.clearDatabase() + + override suspend fun count(search: Search): Long = mFhirEngine.count(search) + + override suspend fun create(vararg resource: Resource, isLocalOnly: Boolean): List = + mFhirEngine.create(*resource, isLocalOnly = isLocalOnly) + + override suspend fun delete(type: ResourceType, id: String) = mFhirEngine.delete(type, id) + + override suspend fun get(type: ResourceType, id: String): Resource = mFhirEngine.get(type, id) + + override suspend fun getLastSyncTimeStamp(): OffsetDateTime? = mFhirEngine.getLastSyncTimeStamp() + + override suspend fun getLocalChanges(type: ResourceType, id: String): List = + mFhirEngine.getLocalChanges(type, id) + + override suspend fun getUnsyncedLocalChanges(): List = + mFhirEngine.getUnsyncedLocalChanges() + + override suspend fun purge(type: ResourceType, id: String, forcePurge: Boolean) = + mFhirEngine.purge(type, id, forcePurge) + + override suspend fun purge(type: ResourceType, ids: Set, forcePurge: Boolean) = + mFhirEngine.purge(type, ids, forcePurge) + + override suspend fun search(search: Search): List> = + mFhirEngine.search(search) + + @Deprecated("To be deprecated.") + override suspend fun syncDownload( + conflictResolver: ConflictResolver, + download: suspend () -> Flow>, + ) = mFhirEngine.syncDownload(conflictResolver, download) + + @Deprecated("To be deprecated.") + override suspend fun syncUpload( + uploadStrategy: UploadStrategy, + upload: + suspend (List, List) -> Flow, + ): Flow = mFhirEngine.syncUpload(uploadStrategy, upload) + + override suspend fun update(vararg resource: Resource) = mFhirEngine.update(*resource) + + override suspend fun withTransaction(block: suspend FhirEngine.() -> Unit) = + mFhirEngine.withTransaction(block) +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt index 1cd68cea66..402ec2bde6 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt @@ -73,7 +73,7 @@ import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.fileExtension import org.smartregister.fhircore.engine.util.extension.generateMissingId import org.smartregister.fhircore.engine.util.extension.interpolate -import org.smartregister.fhircore.engine.util.extension.retrieveCompositionSections +import org.smartregister.fhircore.engine.util.extension.retrieveCompositionSectionsExcludingCustomSearchParameters import org.smartregister.fhircore.engine.util.extension.retrieveRelatedEntitySyncLocationState import org.smartregister.fhircore.engine.util.extension.searchCompositionByIdentifier import org.smartregister.fhircore.engine.util.extension.toTimeZoneString @@ -267,7 +267,7 @@ constructor( localCompositionResource.run { val iconConfigs = - retrieveCompositionSections().filter { + retrieveCompositionSectionsExcludingCustomSearchParameters().filter { it.focus.hasIdentifier() && isIconConfig(it.focus.identifier.value) } if (iconConfigs.isNotEmpty()) { @@ -338,7 +338,8 @@ constructor( } } } else { - composition.retrieveCompositionSections().forEach { sectionComponent -> + composition.retrieveCompositionSectionsExcludingCustomSearchParameters().forEach { + sectionComponent -> if (sectionComponent.hasFocus()) { addBinaryToConfigsJsonMap( sectionComponent.focus, @@ -435,7 +436,8 @@ constructor( val parsedAppId = appId.substringBefore(TYPE_REFERENCE_DELIMITER).trim() val compositionResource = fetchRemoteCompositionByAppId(parsedAppId) compositionResource?.let { composition -> - val compositionSections = composition.retrieveCompositionSections() + val compositionSections = + composition.retrieveCompositionSectionsExcludingCustomSearchParameters() val sectionComponentMap = mutableMapOf>() compositionSections.forEach { sectionComponent -> if (sectionComponent.hasFocus() && sectionComponent.focus.hasReferenceElement()) { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt index d7d642960e..0f4f4f5770 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt @@ -17,9 +17,7 @@ package org.smartregister.fhircore.engine.configuration.app import org.hl7.fhir.r4.model.Coding -import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.ResourceType -import org.hl7.fhir.r4.model.SearchParameter import org.smartregister.fhircore.engine.sync.ResourceTag import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper @@ -73,70 +71,7 @@ interface ConfigService { return tags } - /** - * Provide a list of custom search parameters. - * - * @return list of predefined custom search parameters. - */ - fun provideCustomSearchParameters(): List { - val activeGroupSearchParameter = - SearchParameter().apply { - url = "http://smartregister.org/SearchParameter/group-active" - addBase("Group") - name = ACTIVE_SEARCH_PARAM - code = ACTIVE_SEARCH_PARAM - type = Enumerations.SearchParamType.TOKEN - expression = "Group.active" - description = "Search the active field" - } - - val flagStatusSearchParameter = - SearchParameter().apply { - url = "http://smartregister.org/SearchParameter/flag-status" - addBase("Flag") - name = STATUS_SEARCH_PARAM - code = STATUS_SEARCH_PARAM - type = Enumerations.SearchParamType.TOKEN - expression = "Flag.status" - description = "Search the status field" - } - - val medicationSortSearchParameter = - SearchParameter().apply { - url = MEDICATION_SORT_URL - addBase("Medication") - name = SORT_SEARCH_PARAM - code = SORT_SEARCH_PARAM - type = Enumerations.SearchParamType.NUMBER - expression = "Medication.extension.where(url = '$MEDICATION_SORT_URL').value" - description = "Search the sort field" - } - - val patientSearchParameter = - SearchParameter().apply { - url = "http://smartregister.org/SearchParameter/patient-search" - addBase("Patient") - name = SEARCH_PARAM - code = SEARCH_PARAM - type = Enumerations.SearchParamType.STRING - expression = "Patient.name.text | Patient.identifier.value" - description = "Search patients by name and identifier fields" - } - - return listOf( - activeGroupSearchParameter, - flagStatusSearchParameter, - medicationSortSearchParameter, - patientSearchParameter, - ) - } - companion object { - const val ACTIVE_SEARCH_PARAM = "active" const val APP_VERSION = "AppVersion" - const val STATUS_SEARCH_PARAM = "status" - const val SORT_SEARCH_PARAM = "sort" - const val SEARCH_PARAM = "search" - const val MEDICATION_SORT_URL = "http://smartregister.org/SearchParameter/medication-sort" } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/customsearch/ISearchParametersConfigStore.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/customsearch/ISearchParametersConfigStore.kt new file mode 100644 index 0000000000..7b17cc06b4 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/customsearch/ISearchParametersConfigStore.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.configuration.customsearch + +import org.hl7.fhir.r4.model.Bundle + +interface ISearchParametersConfigStore { + + suspend fun write(bundle: Bundle) + + fun read(): Bundle? +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/customsearch/SearchParametersConfigService.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/customsearch/SearchParametersConfigService.kt new file mode 100644 index 0000000000..f733156bb4 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/customsearch/SearchParametersConfigService.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.configuration.customsearch + +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Enumerations +import org.hl7.fhir.r4.model.ResourceType +import org.hl7.fhir.r4.model.SearchParameter + +class SearchParametersConfigService( + private val store: ISearchParametersConfigStore, +) { + + fun getCustomSearchParameters(): List { + return predefinedCustomSearchParameters + readSavedSearchParameters() + } + + private fun readSavedSearchParameters(): List { + val searchParametersBundle = store.read() ?: return emptyList() + + return searchParametersBundle.entry + .filter { it.resource.resourceType == ResourceType.SearchParameter } + .mapNotNull { it.resource as? SearchParameter } + } + + suspend fun saveBundle(bundle: Bundle) { + store.write(bundle) + } + + /** List of predefined custom search parameters. */ + private val predefinedCustomSearchParameters: List + get() { + val activeGroupSearchParameter = + SearchParameter().apply { + url = "http://smartregister.org/SearchParameter/group-active" + addBase("Group") + name = ACTIVE_SEARCH_PARAM + code = ACTIVE_SEARCH_PARAM + type = Enumerations.SearchParamType.TOKEN + expression = "Group.active" + description = "Search the active field" + } + + val flagStatusSearchParameter = + SearchParameter().apply { + url = "http://smartregister.org/SearchParameter/flag-status" + addBase("Flag") + name = STATUS_SEARCH_PARAM + code = STATUS_SEARCH_PARAM + type = Enumerations.SearchParamType.TOKEN + expression = "Flag.status" + description = "Search the status field" + } + + val medicationSortSearchParameter = + SearchParameter().apply { + url = MEDICATION_SORT_URL + addBase("Medication") + name = SORT_SEARCH_PARAM + code = SORT_SEARCH_PARAM + type = Enumerations.SearchParamType.NUMBER + expression = "Medication.extension.where(url = '$MEDICATION_SORT_URL').value" + description = "Search the sort field" + } + + val patientSearchParameter = + SearchParameter().apply { + url = "http://smartregister.org/SearchParameter/patient-search" + addBase("Patient") + name = SEARCH_PARAM + code = SEARCH_PARAM + type = Enumerations.SearchParamType.STRING + expression = "Patient.name.text | Patient.identifier.value" + description = "Search patients by name and identifier fields" + } + + return listOf( + activeGroupSearchParameter, + flagStatusSearchParameter, + medicationSortSearchParameter, + patientSearchParameter, + ) + } + + companion object { + private const val ACTIVE_SEARCH_PARAM = "active" + private const val STATUS_SEARCH_PARAM = "status" + private const val SORT_SEARCH_PARAM = "sort" + private const val SEARCH_PARAM = "search" + private const val MEDICATION_SORT_URL = + "http://smartregister.org/SearchParameter/medication-sort" + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/CustomSearchModule.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/CustomSearchModule.kt new file mode 100644 index 0000000000..c9a86ca75d --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/CustomSearchModule.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.di + +import android.content.Context +import ca.uhn.fhir.parser.IParser +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import javax.inject.Singleton +import kotlinx.coroutines.withContext +import org.hl7.fhir.r4.model.Bundle +import org.smartregister.fhircore.engine.configuration.customsearch.ISearchParametersConfigStore +import org.smartregister.fhircore.engine.configuration.customsearch.SearchParametersConfigService +import org.smartregister.fhircore.engine.util.DispatcherProvider + +@InstallIn(SingletonComponent::class) +@Module +class CustomSearchModule { + + @Singleton + @Provides + @SearchParametersFileStore + fun provideCustomSearchParameterConfigStore( + @ApplicationContext context: Context, + parser: IParser, + dispatcherProvider: DispatcherProvider, + ): ISearchParametersConfigStore { + val searchParametersFileName = "customSearchParameters.json" + val searchParametersFile = File(context.filesDir, searchParametersFileName) + + return object : ISearchParametersConfigStore { + override suspend fun write(bundle: Bundle) { + withContext(dispatcherProvider.io()) { + FileOutputStream(searchParametersFile).use { + it.write(parser.encodeResourceToString(bundle).toByteArray()) + } + } + } + + override fun read(): Bundle? { + if (!searchParametersFile.exists()) return null + return FileInputStream(searchParametersFile).bufferedReader().use { + parser.parseResource(Bundle::class.java, it) + } + } + } + } + + @Singleton + @Provides + fun provideCustomSearchParameterService( + @SearchParametersFileStore searchParametersStore: ISearchParametersConfigStore, + ): SearchParametersConfigService { + return SearchParametersConfigService(searchParametersStore) + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirEngineModule.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirEngineModule.kt index 2b6afdaab7..6f41a24722 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirEngineModule.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirEngineModule.kt @@ -31,7 +31,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton import org.smartregister.fhircore.engine.BuildConfig +import org.smartregister.fhircore.engine.FhirEngineWrapper import org.smartregister.fhircore.engine.configuration.app.ConfigService +import org.smartregister.fhircore.engine.configuration.customsearch.SearchParametersConfigService import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator import org.smartregister.fhircore.engine.di.NetworkModule.Companion.AUTHORIZATION import org.smartregister.fhircore.engine.di.NetworkModule.Companion.COOKIE @@ -51,35 +53,39 @@ class FhirEngineModule { @ApplicationContext context: Context, tokenAuthenticator: TokenAuthenticator, configService: ConfigService, + customSearchParameterService: SearchParametersConfigService, ): FhirEngine { - FhirEngineProvider.init( - FhirEngineConfiguration( - enableEncryptionIfSupported = !BuildConfig.DEBUG, - databaseErrorStrategy = DatabaseErrorStrategy.RECREATE_AT_OPEN, - ServerConfiguration( - baseUrl = configService.provideAuthConfiguration().fhirServerBaseUrl, - authenticator = tokenAuthenticator, - networkConfiguration = - NetworkConfiguration( - connectionTimeOut = TIMEOUT_DURATION, - readTimeOut = TIMEOUT_DURATION, - writeTimeOut = TIMEOUT_DURATION, - uploadWithGzip = true, - ), - httpLogger = - HttpLogger( - HttpLogger.Configuration( - level = if (BuildConfig.DEBUG) HttpLogger.Level.BODY else HttpLogger.Level.BASIC, - headersToIgnore = listOf(AUTHORIZATION, COOKIE), + return FhirEngineWrapper { + FhirEngineProvider.init( + FhirEngineConfiguration( + enableEncryptionIfSupported = !BuildConfig.DEBUG, + databaseErrorStrategy = DatabaseErrorStrategy.RECREATE_AT_OPEN, + ServerConfiguration( + baseUrl = configService.provideAuthConfiguration().fhirServerBaseUrl, + authenticator = tokenAuthenticator, + networkConfiguration = + NetworkConfiguration( + connectionTimeOut = TIMEOUT_DURATION, + readTimeOut = TIMEOUT_DURATION, + writeTimeOut = TIMEOUT_DURATION, + uploadWithGzip = true, ), - ) { - Timber.tag(QUEST_OKHTTP_CLIENT_TAG).d(it) - }, + httpLogger = + HttpLogger( + HttpLogger.Configuration( + level = if (BuildConfig.DEBUG) HttpLogger.Level.BODY else HttpLogger.Level.BASIC, + headersToIgnore = listOf(AUTHORIZATION, COOKIE), + ), + ) { + Timber.tag(QUEST_OKHTTP_CLIENT_TAG).d(it) + }, + ), + customSearchParameters = customSearchParameterService.getCustomSearchParameters(), ), - customSearchParameters = configService.provideCustomSearchParameters(), - ), - ) - return FhirEngineProvider.getInstance(context) + ) + + FhirEngineProvider.getInstance(context) + } } companion object { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/Qualifiers.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/Qualifiers.kt index 94c62bc4dc..706540542c 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/Qualifiers.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/Qualifiers.kt @@ -43,3 +43,8 @@ annotation class KeycloakRetrofit @Qualifier @Retention(AnnotationRetention.BINARY) annotation class RegularRetrofit + +@ExcludeFromJacocoGeneratedReport +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class SearchParametersFileStore diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/CompositionResourceExtensions.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/CompositionResourceExtensions.kt new file mode 100644 index 0000000000..764e8f284a --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/CompositionResourceExtensions.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.util.extension + +import org.hl7.fhir.r4.model.Composition +import org.hl7.fhir.r4.model.Reference + +private const val SEARCH_PARAMETER_SECTION_SYSTEM_URL = + "http://smartregister.org/CodeSystem/composition-section-codes" +private const val SEARCH_PARAMETER_SECTION_CODE = "custom-search-parameter-bundle" + +/** + * Composition sections can be nested. This function retrieves all the nested composition sections + * and returns a flattened list of all [Composition.SectionComponent] for the given [Composition] + * resource + */ +fun Composition.retrieveCompositionSections(): List { + val sections = mutableListOf() + val sectionsQueue = ArrayDeque() + this.section.forEach { + if (!it.section.isNullOrEmpty()) { + it.section.forEach { sectionComponent -> sectionsQueue.addLast(sectionComponent) } + } + sections.add(it) + } + while (sectionsQueue.isNotEmpty()) { + val sectionComponent = sectionsQueue.removeFirst() + if (!sectionComponent.section.isNullOrEmpty()) { + sectionComponent.section.forEach { sectionsQueue.addLast(it) } + } + sections.add(sectionComponent) + } + return sections +} + +fun Composition.retrieveCompositionSectionsExcludingCustomSearchParameters(): + List { + return retrieveCompositionSections() + .filterNot(Composition.SectionComponent::isASearchParameterSection) +} + +fun Composition.retrieveCustomSearchParametersSection(): List { + return retrieveCompositionSections() + .filter(Composition.SectionComponent::isASearchParameterSection) +} + +fun Composition.SectionComponent.sectionDataReference(): Iterable { + return if (hasFocus() && focus.hasReferenceElement()) entry + focus else entry +} + +fun Composition.SectionComponent.isASearchParameterSection(): Boolean { + return code.coding.any { + it.system.lowercase() == SEARCH_PARAMETER_SECTION_SYSTEM_URL.lowercase() && + it.code.lowercase() == SEARCH_PARAMETER_SECTION_CODE.lowercase() + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ReferenceExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ReferenceExtension.kt index c31a255128..9a30ab09cf 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ReferenceExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ReferenceExtension.kt @@ -26,9 +26,8 @@ fun Reference.extractType(): ResourceType? = if (this.reference.isNullOrEmpty()) { null } else { - this.reference.substringBefore("/" + this.extractId()).substringAfterLast("/").let { - ResourceType.fromCode(it) - } + (this.type ?: this.reference.substringBefore("/" + this.extractId()).substringAfterLast("/")) + .let { ResourceType.fromCode(it) } } fun String.asReference(resourceType: ResourceType): Reference { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt index 4b21562dcc..b9aab274a3 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt @@ -32,7 +32,6 @@ import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.BaseDateTimeType import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding -import org.hl7.fhir.r4.model.Composition import org.hl7.fhir.r4.model.Condition import org.hl7.fhir.r4.model.Consent import org.hl7.fhir.r4.model.Encounter @@ -412,30 +411,6 @@ fun ImplementationGuide.retrieveImplementationGuideDefinitionResources(): return resources } -/** - * Composition sections can be nested. This function retrieves all the nested composition sections - * and returns a flattened list of all [Composition.SectionComponent] for the given [Composition] - * resource - */ -fun Composition.retrieveCompositionSections(): List { - val sections = mutableListOf() - val sectionsQueue = ArrayDeque() - this.section.forEach { - if (!it.section.isNullOrEmpty()) { - it.section.forEach { sectionComponent -> sectionsQueue.addLast(sectionComponent) } - } - sections.add(it) - } - while (sectionsQueue.isNotEmpty()) { - val sectionComponent = sectionsQueue.removeFirst() - if (!sectionComponent.section.isNullOrEmpty()) { - sectionComponent.section.forEach { sectionsQueue.addLast(it) } - } - sections.add(sectionComponent) - } - return sections -} - fun String.resourceClassType(): Class = FhirContext.forR4().getResourceDefinition(this).implementingClass as Class diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/FhirEngineWrapperTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/FhirEngineWrapperTest.kt new file mode 100644 index 0000000000..7f70c6069c --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/FhirEngineWrapperTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine + +import com.google.android.fhir.FhirEngine +import com.google.android.fhir.LocalChange +import com.google.android.fhir.datacapture.extensions.logicalId +import com.google.android.fhir.db.LocalChangeResourceReference +import com.google.android.fhir.delete +import com.google.android.fhir.search.Search +import com.google.android.fhir.sync.ConflictResolver +import com.google.android.fhir.sync.upload.HttpCreateMethod +import com.google.android.fhir.sync.upload.HttpUpdateMethod +import com.google.android.fhir.sync.upload.UploadRequestResult +import com.google.android.fhir.sync.upload.UploadStrategy +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.ResourceType +import org.junit.Before +import org.junit.Test + +class FhirEngineWrapperTest { + + private lateinit var fhirEngineWrapper: FhirEngineWrapper + private lateinit var fhirEngine: FhirEngine + + @Before + fun setUp() { + fhirEngine = mockk(relaxed = true) + fhirEngineWrapper = FhirEngineWrapper { fhirEngine } + } + + @Test + fun clearDatabase() = runTest { + fhirEngineWrapper.clearDatabase() + coVerify { fhirEngine.clearDatabase() } + } + + @Test + fun count() = runTest { + val search = Search(ResourceType.Patient) + fhirEngineWrapper.count(search) + coVerify { fhirEngine.count(search) } + } + + @Test + fun create() = runTest { + val resource = Patient() + fhirEngineWrapper.create(resource, isLocalOnly = false) + coVerify { fhirEngine.create(resource, isLocalOnly = false) } + } + + @Test + fun delete() = runTest { + val resource = Patient().apply { id = "sample-id" } + fhirEngineWrapper.delete(resource.resourceType, resource.logicalId) + coVerify { fhirEngine.delete(resource.resourceType, "sample-id") } + } + + @Test + fun get() = runTest { + fhirEngineWrapper.get(ResourceType.Patient, "test-id") + coVerify { fhirEngine.get(ResourceType.Patient, "test-id") } + } + + @Test + fun getLastSyncTimeStamp() = runTest { + fhirEngineWrapper.getLastSyncTimeStamp() + coVerify { fhirEngine.getLastSyncTimeStamp() } + } + + @Test + fun getLocalChanges() = runTest { + fhirEngineWrapper.getLocalChanges(ResourceType.Patient, "test-id") + coVerify { fhirEngine.getLocalChanges(ResourceType.Patient, "test-id") } + } + + @Test + fun getUnsyncedLocalChanges() = runTest { + fhirEngineWrapper.getUnsyncedLocalChanges() + coVerify { fhirEngine.getUnsyncedLocalChanges() } + } + + @Test + fun purge() = runTest { + fhirEngineWrapper.purge(ResourceType.Patient, "test-id", true) + coVerify { fhirEngine.purge(ResourceType.Patient, "test-id", true) } + } + + @Test + fun purgeIdSets() = runTest { + val ids = setOf("res-1", "res-2", "res-3") + fhirEngineWrapper.purge(ResourceType.Patient, ids, true) + coVerify { fhirEngine.purge(ResourceType.Patient, ids, true) } + } + + @Test + fun search() = runTest { + val search = Search(ResourceType.Patient) + fhirEngineWrapper.search(search) + coVerify { fhirEngine.search(search) } + } + + @Test + fun syncDownload() = runTest { + val conflictResolver = mockk(relaxed = true) + val downloadOp = suspend { emptyFlow>() } + fhirEngineWrapper.syncDownload(conflictResolver, downloadOp) + coVerify { fhirEngine.syncDownload(conflictResolver, downloadOp) } + } + + @Test + fun syncUpload() = runTest { + val uploadStrategy = + UploadStrategy.forIndividualRequest(HttpCreateMethod.PUT, HttpUpdateMethod.PATCH, true) + val uploadOp: + suspend (List, List) -> Flow = + { _, _ -> + emptyFlow() + } + fhirEngineWrapper.syncUpload(uploadStrategy, uploadOp) + coVerify { fhirEngine.syncUpload(uploadStrategy, uploadOp) } + } + + @Test + fun update() = runTest { + val resource = Patient() + fhirEngineWrapper.update(resource) + coVerify { fhirEngine.update(resource) } + } + + @Test + fun withTransaction() = runTest { + val block: suspend FhirEngine.() -> Unit = {} + fhirEngineWrapper.withTransaction(block) + coVerify { fhirEngine.withTransaction(block) } + } +} diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/CompositionResourceExtensionTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/CompositionResourceExtensionTest.kt new file mode 100644 index 0000000000..bc08f9576b --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/CompositionResourceExtensionTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.util.extension + +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Composition +import org.hl7.fhir.r4.model.Composition.SectionComponent +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.smartregister.fhircore.engine.robolectric.RobolectricTest + +class CompositionResourceExtensionTest : RobolectricTest() { + private lateinit var searchParamSection: SectionComponent + private lateinit var defaultConfigSection: SectionComponent + + @Before + fun setUp() { + searchParamSection = + SectionComponent().apply { + code = + CodeableConcept().apply { + coding = + listOf( + Coding().apply { + system = "http://smartregister.org/CodeSystem/composition-section-codes" + code = "custom-search-parameter-bundle" + display = "Custom search param bundle" + }, + ) + } + } + defaultConfigSection = + SectionComponent().apply { + code = + CodeableConcept().apply { + coding = + listOf( + Coding().apply { + system = "http://smartregister.org/CodeSystem/config" + code = "default-config-bundle" + display = "Default config bundle" + }, + ) + } + } + } + + @Test + fun testRetrieveCompositionSectionsExcludingCustomSearchParameters() { + val composition = + Composition().apply { + section = + listOf( + searchParamSection, + defaultConfigSection, + ) + } + + val filteredCompositionSections = + composition.retrieveCompositionSectionsExcludingCustomSearchParameters() + Assert.assertEquals(1, filteredCompositionSections.size) + Assert.assertEquals("default-config-bundle", filteredCompositionSections[0].code.coding[0].code) + } + + @Test + fun testRetrieveCustomSearchParametersSection() { + val composition = + Composition().apply { + section = + listOf( + searchParamSection, + defaultConfigSection, + ) + } + + val filteredCompositionSections = composition.retrieveCustomSearchParametersSection() + Assert.assertEquals(1, filteredCompositionSections.size) + Assert.assertEquals( + "custom-search-parameter-bundle", + filteredCompositionSections[0].code.coding[0].code, + ) + } +} diff --git a/android/quest/build.gradle.kts b/android/quest/build.gradle.kts index 257c598333..f23291d7a1 100644 --- a/android/quest/build.gradle.kts +++ b/android/quest/build.gradle.kts @@ -104,6 +104,8 @@ android { getByName("debug") { enableUnitTestCoverage = BuildConfigs.enableUnitTestCoverage enableAndroidTestCoverage = BuildConfigs.enableAndroidTestCoverage + + kotlinOptions.freeCompilerArgs += "-Xdebug" } create("debugNonProxy") { initWith(getByName("debug")) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModel.kt index 2253006137..aabfc91f2d 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModel.kt @@ -27,11 +27,12 @@ import java.net.UnknownHostException import javax.inject.Inject import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import okhttp3.RequestBody.Companion.toRequestBody -import org.apache.commons.lang3.StringUtils +import org.hl7.fhir.r4.model.Binary import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Composition +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType import org.jetbrains.annotations.VisibleForTesting import org.smartregister.fhircore.engine.BuildConfig @@ -39,18 +40,22 @@ import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry.Companion.DEBUG_SUFFIX import org.smartregister.fhircore.engine.configuration.app.ConfigService +import org.smartregister.fhircore.engine.configuration.customsearch.SearchParametersConfigService import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource import org.smartregister.fhircore.engine.di.NetworkModule import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.extension.decodeResourceFromString import org.smartregister.fhircore.engine.util.extension.encodeResourceToString import org.smartregister.fhircore.engine.util.extension.extractId import org.smartregister.fhircore.engine.util.extension.getActivity import org.smartregister.fhircore.engine.util.extension.launchActivityWithNoBackStackHistory -import org.smartregister.fhircore.engine.util.extension.retrieveCompositionSections +import org.smartregister.fhircore.engine.util.extension.retrieveCompositionSectionsExcludingCustomSearchParameters +import org.smartregister.fhircore.engine.util.extension.retrieveCustomSearchParametersSection import org.smartregister.fhircore.engine.util.extension.retrieveImplementationGuideDefinitionResources +import org.smartregister.fhircore.engine.util.extension.sectionDataReference import org.smartregister.fhircore.quest.ui.login.LoginActivity import org.smartregister.fhircore.quest.ui.main.AppMainActivity import retrofit2.HttpException @@ -68,6 +73,7 @@ constructor( val configService: ConfigService, val configurationRegistry: ConfigurationRegistry, val dispatcherProvider: DispatcherProvider, + val customSearchParameterService: SearchParametersConfigService, ) : ViewModel() { private var _isNonProxy = BuildConfig.IS_NON_PROXY_APK @@ -123,8 +129,6 @@ constructor( compositionResource = if (implementationGuideResource != null) { - configurationRegistry.addOrUpdate(implementationGuideResource) - val compositionReference = implementationGuideResource .retrieveImplementationGuideDefinitionResources()[0] @@ -135,13 +139,9 @@ constructor( val compositionId = compositionIdWithHistory?.substringBefore('/') val compositionVersion = compositionIdWithHistory?.substringAfterLast('/', "") - withContext(dispatcherProvider.io()) { - configurationRegistry.fetchRemoteCompositionById(compositionId, compositionVersion) - } + configurationRegistry.fetchRemoteCompositionById(compositionId, compositionVersion) } else { - withContext(dispatcherProvider.io()) { - configurationRegistry.fetchRemoteCompositionByAppId(appId) - } + configurationRegistry.fetchRemoteCompositionByAppId(appId) } if (compositionResource == null) { @@ -150,50 +150,23 @@ constructor( return@launch } + fetchSearchParameterBundle(compositionResource.retrieveCustomSearchParametersSection()) + // Save composition defaultRepository.createRemote(false, compositionResource) - - compositionResource - .retrieveCompositionSections() - .asSequence() - .filter { it.hasFocus() && it.focus.hasReferenceElement() } - .groupBy { - it.focus.reference.substringBefore( - ConfigurationRegistry.TYPE_REFERENCE_DELIMITER, - missingDelimiterValue = "", - ) - } - .filter { it.key == ResourceType.Binary.name || it.key == ResourceType.Parameters.name } - .forEach { entry: Map.Entry> -> - val chunkedResourceIdList = - entry.value.chunked(ConfigurationRegistry.MANIFEST_PROCESSOR_BATCH_SIZE) - chunkedResourceIdList.forEach { parentIt -> - Timber.d( - "Fetching config resource ${entry.key}: with ids ${StringUtils.join(parentIt,",")}", - ) - - val resultBundle: Bundle = - if (isNonProxy()) { - fhirResourceDataSourceGetBundle( - entry.key, - parentIt.map { it.focus.extractId() }, - ) - } else { - fhirResourceDataSource.post( - requestBody = - generateRequestBundle(entry.key, parentIt.map { it.focus.extractId() }) - .encodeResourceToString() - .toRequestBody(NetworkModule.JSON_MEDIA_TYPE), - ) - } - - resultBundle.entry.forEach { bundleEntryComponent -> - if (bundleEntryComponent.resource != null) { - defaultRepository.createRemote(false, bundleEntryComponent.resource) - } - } - } - } + implementationGuideResource?.let { configurationRegistry.addOrUpdate(it) } + + val appConfigSectionReferences = + compositionResource + .retrieveCompositionSectionsExcludingCustomSearchParameters() + .asSequence() + .flatMap { it.sectionDataReference() } + .groupBy(::referenceResourceTypeString) + .filterKeys(::isReferenceToAppConfig) + appConfigSectionReferences.download { resultBundle -> + val resources = resultBundle.entry.mapNotNull { it.resource } + defaultRepository.createRemote(false, *resources.toTypedArray()) + } Timber.d("Done fetching application configurations remotely") loadConfigurations(context) @@ -250,6 +223,80 @@ constructor( @VisibleForTesting fun isDebugVariant() = BuildConfig.DEBUG + private suspend fun fetchSearchParameterBundle( + searchParameterCompositionSection: List, + ): Bundle? { + val searchParameterBinary = + searchParameterCompositionSection + .flatMap { it.sectionDataReference() } + .groupBy(::referenceResourceTypeString) + .download() + .flatMap { bundle -> bundle.entry.mapNotNull { it.resource as? Binary } } + + val searchParameterBundle = + searchParameterBinary + .map { it.content.decodeToString() } + .map { it.decodeResourceFromString() } + .filter { it.entry.isNotEmpty() } + .reduceOrNull { acc, bundle -> acc.apply { entry = entry + bundle.entry } } + + return searchParameterBundle?.also { customSearchParameterService.saveBundle(it) } + } + + private suspend inline fun > Map.download( + onSuccess: (Bundle) -> Unit = {}, + ): List { + return entries + .flatMap { (key, value) -> + value.chunked(ConfigurationRegistry.MANIFEST_PROCESSOR_BATCH_SIZE) { key to it } + } + .map { (resourceType, references) -> + downloadConfigResourceReferencesWithRetry(resourceType, references) + } + .onEach(onSuccess) + } + + private suspend fun downloadConfigResourceReferencesWithRetry( + resourceType: String, + references: Iterable, + ): Bundle { + val downloadResources = suspend { downloadConfigResourceReferences(resourceType, references) } + return kotlin + .runCatching { downloadResources() } + .getOrElse { + // retry + downloadResources() + } + } + + private suspend fun downloadConfigResourceReferences( + resourceType: String, + references: Iterable, + ): Bundle { + return if (isNonProxy()) { + fhirResourceDataSourceGetBundle( + resourceType, + references.map { it.extractId() }, + ) + } else { + fhirResourceDataSource.post( + requestBody = + generateRequestBundle(resourceType, references.map { it.extractId() }) + .encodeResourceToString() + .toRequestBody(NetworkModule.JSON_MEDIA_TYPE), + ) + } + } + + private fun referenceResourceTypeString(reference: Reference) = + reference.reference.substringBefore( + ConfigurationRegistry.TYPE_REFERENCE_DELIMITER, + missingDelimiterValue = "", + ) + + private fun isReferenceToAppConfig(reference: String) = + reference == ResourceType.Binary.name || reference == ResourceType.Parameters.name + private fun generateRequestBundle(resourceType: String, idList: List): Bundle { val bundleEntryComponents = mutableListOf() @@ -279,7 +326,7 @@ constructor( resourceIds.forEach { val responseBundle = - fhirResourceDataSource.getResource("$resourceType?${Composition.SP_RES_ID}=$it") + fhirResourceDataSource.getResource("$resourceType?${Resource.SP_RES_ID}=$it") responseBundle.let { bundleEntryComponents.add( Bundle.BundleEntryComponent().apply { diff --git a/android/quest/src/test/java/org/smartregister/fhircore/engine/configuration/customsearch/SearchParametersConfigServiceTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/engine/configuration/customsearch/SearchParametersConfigServiceTest.kt new file mode 100644 index 0000000000..d95919226b --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/engine/configuration/customsearch/SearchParametersConfigServiceTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.configuration.customsearch + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.runs +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.SearchParameter +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class SearchParametersConfigServiceTest { + + private lateinit var searchParametersConfigService: SearchParametersConfigService + private lateinit var searchParameterConfigStore: ISearchParametersConfigStore + private val configStoreSearchParameter: SearchParameter = + SearchParameter().apply { code = "config-store-test-search" } + + @Before + fun setUp() { + searchParameterConfigStore = spyk { + every { read() } returns + Bundle().apply { + addEntry( + Bundle.BundleEntryComponent().apply { resource = configStoreSearchParameter }, + ) + } + coEvery { write(any()) } just runs + } + searchParametersConfigService = SearchParametersConfigService(searchParameterConfigStore) + } + + @Test + fun getCustomSearchParameters() { + val result = searchParametersConfigService.getCustomSearchParameters() + verify { searchParameterConfigStore.read() } + Assert.assertTrue(configStoreSearchParameter in result) + } + + @Test + fun getCustomSearchParametersReturnsDefaultSearchParams() { + val result = searchParametersConfigService.getCustomSearchParameters() + verify { searchParameterConfigStore.read() } + Assert.assertTrue(result[0].url.contains("group-active")) + Assert.assertTrue(result[1].url.contains("flag-status")) + Assert.assertTrue(result[2].url.contains("medication-sort")) + Assert.assertTrue(result[3].url.contains("patient-search")) + } + + @Test + fun saveBundle() = runTest { + val searchParamBundle = Bundle() + searchParametersConfigService.saveBundle(searchParamBundle) + coVerify { searchParameterConfigStore.write(searchParamBundle) } + } +} diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModelTest.kt index c04891a87d..89328f5a31 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModelTest.kt @@ -58,6 +58,8 @@ import org.mockito.ArgumentMatchers.anyString import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ConfigService +import org.smartregister.fhircore.engine.configuration.customsearch.ISearchParametersConfigStore +import org.smartregister.fhircore.engine.configuration.customsearch.SearchParametersConfigService import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource @@ -103,6 +105,18 @@ class AppSettingViewModelTest : RobolectricTest() { configService = configService, configurationRegistry = Faker.buildTestConfigurationRegistry(), dispatcherProvider = dispatcherProvider, + customSearchParameterService = + SearchParametersConfigService( + object : ISearchParametersConfigStore { + override suspend fun write(bundle: Bundle) { + // no-op + } + + override fun read(): Bundle? { + return null + } + }, + ), ), ) } diff --git a/docs/engineering/app/configuring/config-types/customSearch.mdx b/docs/engineering/app/configuring/config-types/customSearch.mdx new file mode 100644 index 0000000000..802555ea0b --- /dev/null +++ b/docs/engineering/app/configuring/config-types/customSearch.mdx @@ -0,0 +1,90 @@ +--- +title: Custom Searching +--- + +Additional custom [SearchParameter](https://www.hl7.org/fhir/searchparameter.html)s can be configured setting a +[Binary](https://www.hl7.org/fhir/binary.html) that would be referenced though the app's Composition resource. + +The contents of the [Binary] configuration are a [Bundle](https://www.hl7.org/fhir/bundle.html) of type `collection`. +The entries in the Bundle should be a valid SearchParameter resource +An example + +``` json +{ + "resourceType": "Bundle", + "type": "collection", + "entry": [ + { + "resource": { + "resourceType": "SearchParameter", + "url": "http://smartregister.org/SearchParameter/patient-yellow", + "name": "yellow", + "code": "yellow", + "base": ["Patient"], + "type": "string", + "expression": "Patient.name.text.select('YELLOW ' + toString())", + "description": "Search patients by yellow" + } + } + ] +} +``` + +In the app, the new custom search parameter could then be used in the form + +``` +fhirEngine.search { + filter(StringClientParam("yellow"), { + value = "yellow" + modifier = StringFilterModifier.STARTS_WITH + }) +} +``` + +## Composition Section + +To differentiate the search parameters config from the other configs, when linking in the app's Composition resource, +it's Composition [section](https://www.hl7.org/fhir/composition-definitions.html#Composition.section) should have the +[code](https://www.hl7.org/fhir/composition-definitions.html#Composition.section.code) + +``` + "code": { + "coding": [ + { + "system": "http://smartregister.org/CodeSystem/composition-section-codes", + "code": "custom-search-parameter-bundle" + } + ] + } +``` + +An example of a valid SearchParameter config in a Composition would therefore look like + +``` +{ + "resourceType": "Composition", + "section": [ + ..., + { + "title": "Custom Search Parameters", + "mode": "working", + "code": { + "coding": [ + { + "system": "http://smartregister.org/CodeSystem/composition-section-codes", + "code": "custom-search-parameter-bundle" + } + ] + }, + "entry": [ + { + "reference": "Binary/searchParamConfigTestBinary" + } + ] + } + ] +} +``` + +### Issue +https://github.com/opensrp/fhircore/issues/3532