From 6b43d6543926fd948fe88ce37bc8fe798fada74d Mon Sep 17 00:00:00 2001 From: Josh Soref <2119212+jsoref@users.noreply.github.com> Date: Tue, 3 Oct 2023 13:43:33 -0400 Subject: [PATCH 1/9] Spelling (#2193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * spelling: definition Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: expected Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: github Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: initial Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: into Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: knowledge Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: preexisting Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: presumably Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: wifi Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> * spelling: workaround Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> --------- Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com> Co-authored-by: Michael Vorburger ⛑️ --- README.md | 2 +- catalog/src/main/assets/component_open_choice.json | 2 +- .../main/assets/component_open_choice_with_validation.json | 2 +- .../datacapture/extensions/MoreQuestionnaireItemComponents.kt | 2 +- .../android/fhir/datacapture/mapping/ResourceMapperTest.kt | 2 +- docs/data-capture/scripts/navigation-loader.js | 4 ++-- .../-periodic-sync-configuration/sync-constraints.html | 2 +- docs/engine/scripts/navigation-loader.js | 4 ++-- docs/workflow/scripts/navigation-loader.js | 4 ++-- engine/src/main/java/com/google/android/fhir/sync/Config.kt | 2 +- .../com/google/android/fhir/knowledge/KnowledgeManager.kt | 2 +- .../com/google/android/fhir/workflow/testing/CqlBuilder.kt | 4 ++-- 12 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e586745802..28264063d7 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ To contribute to the project, please see [Contributing](https://github.com/googl You can create a [GitHub issue](https://github.com/google/android-fhir/issues) for bugs and feature requests. -In case you find any security bug, please do NOT create a Github issue. Instead, email us at . +In case you find any security bug, please do NOT create a GitHub issue. Instead, email us at . If you want to provide any feedback or discuss use cases you can: * email us at diff --git a/catalog/src/main/assets/component_open_choice.json b/catalog/src/main/assets/component_open_choice.json index e75d1fc1a0..f9a8b36b72 100644 --- a/catalog/src/main/assets/component_open_choice.json +++ b/catalog/src/main/assets/component_open_choice.json @@ -20,7 +20,7 @@ } } ], - "text": "Do you have any pre-existing health conditions?", + "text": "Do you have any preexisting health conditions?", "item": [ { "linkId": "1.1", diff --git a/catalog/src/main/assets/component_open_choice_with_validation.json b/catalog/src/main/assets/component_open_choice_with_validation.json index f72a3d32ee..b12fc6f908 100644 --- a/catalog/src/main/assets/component_open_choice_with_validation.json +++ b/catalog/src/main/assets/component_open_choice_with_validation.json @@ -18,7 +18,7 @@ } ], "linkId": "1", - "text": "Do you have any pre-existing health conditions?", + "text": "Do you have any preexisting health conditions?", "required": true, "item": [ { diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt index 833ca19868..5ae1cd30b8 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt @@ -845,7 +845,7 @@ fun Questionnaire.QuestionnaireItemComponent.createQuestionnaireResponseItem(): } /** - * Returns a list of answers from the initial values of the questionnaire item. `null` if no intial + * Returns a list of answers from the initial values of the questionnaire item. `null` if no initial * value. */ private fun Questionnaire.QuestionnaireItemComponent.createQuestionnaireResponseItemAnswers(): diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt index 8dc916d45d..55ce7f3bd0 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt @@ -1078,7 +1078,7 @@ class ResourceMapperTest { } @Test - fun `extract_updateIntegerObservationForDecimalDefination_shouldUpdateAsDecimal() `() = + fun `extract_updateIntegerObservationForDecimalDefinition_shouldUpdateAsDecimal() `() = runBlocking { @Language("JSON") val questionnaireJson = diff --git a/docs/data-capture/scripts/navigation-loader.js b/docs/data-capture/scripts/navigation-loader.js index 7b6aeb1f46..fd3ee42365 100644 --- a/docs/data-capture/scripts/navigation-loader.js +++ b/docs/data-capture/scripts/navigation-loader.js @@ -52,8 +52,8 @@ revealParents = (part) => { }; /* - This is a work-around for safari being IE of our times. - It doesn't fire a DOMContentLoaded, presumabely because eventListener is added after it wants to do it + This is a workaround for safari being IE of our times. + It doesn't fire a DOMContentLoaded, presumably because eventListener is added after it wants to do it */ if (document.readyState == 'loading') { window.addEventListener('DOMContentLoaded', () => { diff --git a/docs/engine/engine/com.google.android.fhir.sync/-periodic-sync-configuration/sync-constraints.html b/docs/engine/engine/com.google.android.fhir.sync/-periodic-sync-configuration/sync-constraints.html index 412a7138ba..8d4dcc275c 100644 --- a/docs/engine/engine/com.google.android.fhir.sync/-periodic-sync-configuration/sync-constraints.html +++ b/docs/engine/engine/com.google.android.fhir.sync/-periodic-sync-configuration/sync-constraints.html @@ -28,7 +28,7 @@

syncConstraints

val syncConstraints: Constraints
-

Constraints that specify the requirements needed before the synchronisation is triggered. E.g. network type (Wifi, 3G etc), the device should be charging etc.

+

Constraints that specify the requirements needed before the synchronisation is triggered. E.g. network type (WiFi, 3G etc), the device should be charging etc.

Sources

diff --git a/docs/engine/scripts/navigation-loader.js b/docs/engine/scripts/navigation-loader.js index 7b6aeb1f46..fd3ee42365 100644 --- a/docs/engine/scripts/navigation-loader.js +++ b/docs/engine/scripts/navigation-loader.js @@ -52,8 +52,8 @@ revealParents = (part) => { }; /* - This is a work-around for safari being IE of our times. - It doesn't fire a DOMContentLoaded, presumabely because eventListener is added after it wants to do it + This is a workaround for safari being IE of our times. + It doesn't fire a DOMContentLoaded, presumably because eventListener is added after it wants to do it */ if (document.readyState == 'loading') { window.addEventListener('DOMContentLoaded', () => { diff --git a/docs/workflow/scripts/navigation-loader.js b/docs/workflow/scripts/navigation-loader.js index 7b6aeb1f46..fd3ee42365 100644 --- a/docs/workflow/scripts/navigation-loader.js +++ b/docs/workflow/scripts/navigation-loader.js @@ -52,8 +52,8 @@ revealParents = (part) => { }; /* - This is a work-around for safari being IE of our times. - It doesn't fire a DOMContentLoaded, presumabely because eventListener is added after it wants to do it + This is a workaround for safari being IE of our times. + It doesn't fire a DOMContentLoaded, presumably because eventListener is added after it wants to do it */ if (document.readyState == 'loading') { window.addEventListener('DOMContentLoaded', () => { diff --git a/engine/src/main/java/com/google/android/fhir/sync/Config.kt b/engine/src/main/java/com/google/android/fhir/sync/Config.kt index ba35177c0b..194890b2c5 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/Config.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/Config.kt @@ -48,7 +48,7 @@ object SyncDataParams { class PeriodicSyncConfiguration( /** * Constraints that specify the requirements needed before the synchronisation is triggered. E.g. - * network type (Wifi, 3G etc), the device should be charging etc. + * network type (WiFi, 3G etc), the device should be charging etc. */ val syncConstraints: Constraints = Constraints.Builder().build(), diff --git a/knowledge/src/main/java/com/google/android/fhir/knowledge/KnowledgeManager.kt b/knowledge/src/main/java/com/google/android/fhir/knowledge/KnowledgeManager.kt index adeb206154..784882d718 100644 --- a/knowledge/src/main/java/com/google/android/fhir/knowledge/KnowledgeManager.kt +++ b/knowledge/src/main/java/com/google/android/fhir/knowledge/KnowledgeManager.kt @@ -72,7 +72,7 @@ internal constructor( } } - /** Imports the Knolwedge Artifact from the provided [file] to the default dependency. */ + /** Imports the Knowledge Artifact from the provided [file] to the default dependency. */ suspend fun install(file: File) { importFile(null, file) } diff --git a/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/CqlBuilder.kt b/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/CqlBuilder.kt index 53e52b4a98..0913bd07ad 100644 --- a/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/CqlBuilder.kt +++ b/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/CqlBuilder.kt @@ -87,7 +87,7 @@ object CqlBuilder : Loadable() { } /** - * Assembles an ELM Library exported as a JSON in to a FHIRLibrary + * Assembles an ELM Library exported as a JSON into a FHIRLibrary * * @param jsonElmStr the JSON representation of the ELM Library * @param libName the Library name @@ -218,7 +218,7 @@ object CqlBuilder : Loadable() { } fun generatesFhirLibraryEqualsTo(expectedFhirAssetName: String): CompiledCql { - // Given the ELM is the same, builds the lib with the expented, not the new ELM to make sure + // Given the ELM is the same, builds the lib with the expected, not the new ELM to make sure // the base 64 representation of the Library matches. val library = assembleFhirLib( From ddb2a7f32a24cb17a17dc9936552944b951a79c0 Mon Sep 17 00:00:00 2001 From: Kostia Tarasenko Date: Wed, 4 Oct 2023 18:54:38 +0200 Subject: [PATCH 2/9] Implement a simple NpmManager (#2028) * Add npm packager manager classes * Rename .java to .kt * Remove GsonDependency, remove rednundant classes * Import downloaded ig * Import downloaded ig * Add SimplePackageCacheManager * Remove npmpackage from npmpackagemanager * Add npm manager * Implement NpmManager * Rollback version updates * Address review suggestion: - Rename ImplementationGuide.kt to Dependency.kt - Drop the NpmPackageManager.kt * Spottless apply * Fix tests * Use current module in workflow library * Run spotless apply * Remove print statement * Use kotlin stream functions * Remove dead code * Remove dead code * Move FhirOperatorBuilder into FhirOperator.kt file * Address comments and renaming --------- Co-authored-by: Jing Tang --- buildSrc/src/main/kotlin/Dependencies.kt | 4 + knowledge/build.gradle.kts | 6 +- .../android/fhir/knowledge/FhirNpmPackage.kt | 27 +++++ .../fhir/knowledge/KnowledgeManager.kt | 49 +++++--- .../entities/ImplementationGuideEntity.kt | 6 +- .../LocalFhirNpmPackageMetadata.kt} | 20 ++-- .../fhir/knowledge/npm/NpmFileManager.kt | 74 ++++++++++++ .../knowledge/npm/OkHttpPackageDownloader.kt | 112 ++++++++++++++++++ .../fhir/knowledge/npm/PackageDownloader.kt | 29 +++++ .../fhir/knowledge/KnowledgeManagerNpmTest.kt | 105 ++++++++++++++++ .../fhir/knowledge/KnowledgeManagerTest.kt | 53 +++++++-- .../fhir/knowledge/npm/NpmFileManagerTest.kt | 65 ++++++++++ .../npm/OkHttpPackageDownloaderTest.kt | 80 +++++++++++++ knowledge/testdata/anc-cds/package.json | 9 ++ .../test-package#13.3.7/package/package.json | 13 ++ .../testdata/okhttp_downloader/package.tgz | Bin 0 -> 455 bytes .../benchmark/F_CqlEvaluatorBenchmark.kt | 10 +- workflow/build.gradle.kts | 3 +- .../FhirOperatorLibraryEvaluateTest.kt | 8 +- .../android/fhir/workflow/FhirOperator.kt | 24 ++++ .../fhir/workflow/FhirOperatorBuilder.kt | 62 ---------- .../android/fhir/workflow/FhirOperatorTest.kt | 4 +- 22 files changed, 654 insertions(+), 109 deletions(-) create mode 100644 knowledge/src/main/java/com/google/android/fhir/knowledge/FhirNpmPackage.kt rename knowledge/src/main/java/com/google/android/fhir/knowledge/{ImplementationGuide.kt => npm/LocalFhirNpmPackageMetadata.kt} (59%) create mode 100644 knowledge/src/main/java/com/google/android/fhir/knowledge/npm/NpmFileManager.kt create mode 100644 knowledge/src/main/java/com/google/android/fhir/knowledge/npm/OkHttpPackageDownloader.kt create mode 100644 knowledge/src/main/java/com/google/android/fhir/knowledge/npm/PackageDownloader.kt create mode 100644 knowledge/src/test/java/com/google/android/fhir/knowledge/KnowledgeManagerNpmTest.kt create mode 100644 knowledge/src/test/java/com/google/android/fhir/knowledge/npm/NpmFileManagerTest.kt create mode 100644 knowledge/src/test/java/com/google/android/fhir/knowledge/npm/OkHttpPackageDownloaderTest.kt create mode 100644 knowledge/testdata/anc-cds/package.json create mode 100644 knowledge/testdata/cache_manager/test-package#13.3.7/package/package.json create mode 100644 knowledge/testdata/okhttp_downloader/package.tgz delete mode 100644 workflow/src/main/java/com/google/android/fhir/workflow/FhirOperatorBuilder.kt diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 99fbb87fc8..67fbcd8903 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -159,6 +159,9 @@ object Dependencies { "$androidFhirGroup:$androidFhirEngineModule:${Versions.androidFhirEngine}" const val androidFhirKnowledge = "$androidFhirGroup:knowledge:${Versions.androidFhirKnowledge}" + const val apacheCommonsCompress = + "org.apache.commons:commons-compress:${Versions.apacheCommonsCompress}" + const val desugarJdkLibs = "com.android.tools:desugar_jdk_libs:${Versions.desugarJdkLibs}" const val fhirUcum = "org.fhir:ucum:${Versions.fhirUcum}" const val gson = "com.google.code.gson:gson:${Versions.gson}" @@ -243,6 +246,7 @@ object Dependencies { const val androidFhirCommon = "0.1.0-alpha04" const val androidFhirEngine = "0.1.0-beta03" const val androidFhirKnowledge = "0.1.0-alpha01" + const val apacheCommonsCompress = "1.21" const val desugarJdkLibs = "2.0.3" const val caffeine = "2.9.1" const val fhirUcum = "1.0.3" diff --git a/knowledge/build.gradle.kts b/knowledge/build.gradle.kts index 19de08f484..7003e1009a 100644 --- a/knowledge/build.gradle.kts +++ b/knowledge/build.gradle.kts @@ -86,11 +86,14 @@ dependencies { coreLibraryDesugaring(Dependencies.desugarJdkLibs) implementation(Dependencies.Kotlin.stdlib) + implementation(Dependencies.Kotlin.kotlinCoroutinesCore) implementation(Dependencies.Lifecycle.liveDataKtx) implementation(Dependencies.Room.ktx) implementation(Dependencies.Room.runtime) implementation(Dependencies.timber) - implementation(Dependencies.Kotlin.kotlinCoroutinesCore) + implementation(Dependencies.http) + implementation(Dependencies.HapiFhir.fhirCoreConvertors) + implementation(Dependencies.apacheCommonsCompress) kapt(Dependencies.Room.compiler) @@ -100,6 +103,7 @@ dependencies { testImplementation(Dependencies.Kotlin.kotlinCoroutinesTest) testImplementation(Dependencies.mockitoInline) testImplementation(Dependencies.mockitoKotlin) + testImplementation(Dependencies.mockWebServer) testImplementation(Dependencies.robolectric) testImplementation(Dependencies.truth) } diff --git a/knowledge/src/main/java/com/google/android/fhir/knowledge/FhirNpmPackage.kt b/knowledge/src/main/java/com/google/android/fhir/knowledge/FhirNpmPackage.kt new file mode 100644 index 0000000000..fd79e348ef --- /dev/null +++ b/knowledge/src/main/java/com/google/android/fhir/knowledge/FhirNpmPackage.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.knowledge + +/** + * A FHIR NPM Package as defined by the FHIR specification. + * + * See https://hl7.org/fhir/packages.html for the published FHIR NPM Packages specification. + * + * See https://confluence.hl7.org/display/FHIR/NPM+Package+Specification for more info under the + * management of FHIR Infrastructure. + */ +data class FhirNpmPackage(val name: String, val version: String, val canonical: String? = null) diff --git a/knowledge/src/main/java/com/google/android/fhir/knowledge/KnowledgeManager.kt b/knowledge/src/main/java/com/google/android/fhir/knowledge/KnowledgeManager.kt index 784882d718..4861341a18 100644 --- a/knowledge/src/main/java/com/google/android/fhir/knowledge/KnowledgeManager.kt +++ b/knowledge/src/main/java/com/google/android/fhir/knowledge/KnowledgeManager.kt @@ -23,6 +23,9 @@ import ca.uhn.fhir.parser.IParser import com.google.android.fhir.knowledge.db.impl.KnowledgeDatabase import com.google.android.fhir.knowledge.db.impl.entities.ResourceMetadataEntity import com.google.android.fhir.knowledge.db.impl.entities.toEntity +import com.google.android.fhir.knowledge.npm.NpmFileManager +import com.google.android.fhir.knowledge.npm.OkHttpPackageDownloader +import com.google.android.fhir.knowledge.npm.PackageDownloader import java.io.File import java.io.FileInputStream import kotlinx.coroutines.Dispatchers @@ -37,27 +40,41 @@ import timber.log.Timber class KnowledgeManager internal constructor( private val knowledgeDatabase: KnowledgeDatabase, + dataFolder: File, private val jsonParser: IParser = FhirContext.forR4().newJsonParser(), + private val npmFileManager: NpmFileManager = + NpmFileManager(File(dataFolder, ".fhir_package_cache")), + private val packageDownloader: PackageDownloader = OkHttpPackageDownloader(npmFileManager), ) { - private val knowledgeDao = knowledgeDatabase.knowledgeDao() /** - * * Checks if the [implementationGuides] are present in DB. If necessary, downloads the - * dependencies from NPM and imports data from the package manager (populates the metadata of - * the FHIR Resources) + * Checks if the [fhirNpmPackages] are present in DB. If necessary, downloads the dependencies + * from NPM and imports data from the package manager (populates the metadata of the FHIR + * Resources). */ - suspend fun install(vararg implementationGuides: ImplementationGuide) { - TODO("[1937]Not implemented yet ") + suspend fun install(vararg fhirNpmPackages: FhirNpmPackage) { + fhirNpmPackages + .filter { knowledgeDao.getImplementationGuide(it.name, it.version) == null } + .forEach { + val npmPackage = + if (npmFileManager.containsPackage(it.name, it.version)) { + npmFileManager.getPackage(it.name, it.version) + } else { + packageDownloader.downloadPackage(it, PACKAGE_SERVER) + } + install(it, npmPackage.rootDirectory) + install(*npmPackage.dependencies.toTypedArray()) + } } /** - * Checks if the [implementationGuide] is present in DB. If necessary, populates the database with - * the metadata of FHIR Resource from the provided [rootDirectory]. + * Checks if the [fhirNpmPackage] is present in DB. If necessary, populates the database with the + * metadata of FHIR Resource from the provided [rootDirectory]. */ - suspend fun install(implementationGuide: ImplementationGuide, rootDirectory: File) { + suspend fun install(fhirNpmPackage: FhirNpmPackage, rootDirectory: File) { // TODO(ktarasenko) copy files to the safe space? - val igId = knowledgeDao.insert(implementationGuide.toEntity(rootDirectory)) + val igId = knowledgeDao.insert(fhirNpmPackage.toEntity(rootDirectory)) rootDirectory.listFiles()?.forEach { file -> try { val resource = jsonParser.parseResource(FileInputStream(file)) @@ -101,10 +118,9 @@ internal constructor( } /** Deletes Implementation Guide, cleans up files. */ - suspend fun delete(vararg igDependencies: ImplementationGuide) { + suspend fun delete(vararg igDependencies: FhirNpmPackage) { igDependencies.forEach { igDependency -> - val igEntity = - knowledgeDao.getImplementationGuide(igDependency.packageId, igDependency.version) + val igEntity = knowledgeDao.getImplementationGuide(igDependency.name, igDependency.version) if (igEntity != null) { knowledgeDao.deleteImplementationGuide(igEntity) igEntity.rootDirectory.deleteRecursively() @@ -150,15 +166,20 @@ internal constructor( companion object { private const val DB_NAME = "knowledge.db" + private const val PACKAGE_SERVER = "https://packages.fhir.org/packages/" /** Creates an [KnowledgeManager] backed by the Room DB. */ fun create(context: Context) = KnowledgeManager( Room.databaseBuilder(context, KnowledgeDatabase::class.java, DB_NAME).build(), + context.dataDir, ) /** Creates an [KnowledgeManager] backed by the in-memory DB. */ fun createInMemory(context: Context) = - KnowledgeManager(Room.inMemoryDatabaseBuilder(context, KnowledgeDatabase::class.java).build()) + KnowledgeManager( + Room.inMemoryDatabaseBuilder(context, KnowledgeDatabase::class.java).build(), + context.dataDir, + ) } } diff --git a/knowledge/src/main/java/com/google/android/fhir/knowledge/db/impl/entities/ImplementationGuideEntity.kt b/knowledge/src/main/java/com/google/android/fhir/knowledge/db/impl/entities/ImplementationGuideEntity.kt index e9c0a49054..a64b8d1dc8 100644 --- a/knowledge/src/main/java/com/google/android/fhir/knowledge/db/impl/entities/ImplementationGuideEntity.kt +++ b/knowledge/src/main/java/com/google/android/fhir/knowledge/db/impl/entities/ImplementationGuideEntity.kt @@ -19,7 +19,7 @@ package com.google.android.fhir.knowledge.db.impl.entities import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey -import com.google.android.fhir.knowledge.ImplementationGuide +import com.google.android.fhir.knowledge.FhirNpmPackage import java.io.File /** @@ -46,6 +46,6 @@ internal data class ImplementationGuideEntity( val rootDirectory: File, ) -internal fun ImplementationGuide.toEntity(rootFolder: File): ImplementationGuideEntity { - return ImplementationGuideEntity(0L, uri, packageId, version, rootFolder) +internal fun FhirNpmPackage.toEntity(rootFolder: File): ImplementationGuideEntity { + return ImplementationGuideEntity(0L, canonical ?: "", name, version, rootFolder) } diff --git a/knowledge/src/main/java/com/google/android/fhir/knowledge/ImplementationGuide.kt b/knowledge/src/main/java/com/google/android/fhir/knowledge/npm/LocalFhirNpmPackageMetadata.kt similarity index 59% rename from knowledge/src/main/java/com/google/android/fhir/knowledge/ImplementationGuide.kt rename to knowledge/src/main/java/com/google/android/fhir/knowledge/npm/LocalFhirNpmPackageMetadata.kt index 07caa6bdbe..d5f3f65176 100644 --- a/knowledge/src/main/java/com/google/android/fhir/knowledge/ImplementationGuide.kt +++ b/knowledge/src/main/java/com/google/android/fhir/knowledge/npm/LocalFhirNpmPackageMetadata.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Google LLC + * Copyright 2023 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,10 +14,16 @@ * limitations under the License. */ -package com.google.android.fhir.knowledge +package com.google.android.fhir.knowledge.npm -/** - * Holds Implementation Guide attributes. Used to define dependencies, load dependencies from - * Package Manager - */ -data class ImplementationGuide(val packageId: String, val version: String, val uri: String) +import com.google.android.fhir.knowledge.FhirNpmPackage +import java.io.File + +/** Downloaded FHIR NPM Package metadata. */ +data class LocalFhirNpmPackageMetadata( + val packageId: String, + val version: String, + val canonical: String?, + val dependencies: List, + val rootDirectory: File, +) diff --git a/knowledge/src/main/java/com/google/android/fhir/knowledge/npm/NpmFileManager.kt b/knowledge/src/main/java/com/google/android/fhir/knowledge/npm/NpmFileManager.kt new file mode 100644 index 0000000000..a55ec13c7f --- /dev/null +++ b/knowledge/src/main/java/com/google/android/fhir/knowledge/npm/NpmFileManager.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.knowledge.npm + +import com.google.android.fhir.knowledge.FhirNpmPackage +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject + +/** Manages stored NPM packages. */ +internal class NpmFileManager(private val cacheRoot: File) { + + /** + * Returns the NpmPackage for the given [packageId] and [version] from cache or `null` if the + * package is not cached. + */ + suspend fun getPackage(packageId: String, version: String): LocalFhirNpmPackageMetadata { + return withContext(Dispatchers.IO) { + val packageFolder = File(getPackageFolder(packageId, version), "package") + readNpmPackage(packageFolder) + } + } + + /** + * Returns the NpmPackage for the given [packageId] and [version] from cache or `null` if the + * package is not cached. + */ + suspend fun containsPackage(packageId: String, version: String): Boolean { + return withContext(Dispatchers.IO) { + val packageFolder = File(getPackageFolder(packageId, version), "package") + val packageJson = File(packageFolder, "package.json") + packageJson.exists() + } + } + + /** Returns the package folder for the given [packageId] and [version]. */ + fun getPackageFolder(packageId: String, version: String) = File(cacheRoot, "$packageId#$version") + + /** Creates an [LocalFhirNpmPackageMetadata] parsing the package manifest file. */ + private fun readNpmPackage(packageFolder: File): LocalFhirNpmPackageMetadata { + val packageJson = File(packageFolder, "package.json") + val json = JSONObject(packageJson.readText()) + with(json) { + val dependenciesList = optJSONObject("dependencies") + val dependencies = + dependenciesList?.keys()?.asSequence()?.map { key -> + FhirNpmPackage(key, dependenciesList.getString(key)) + } + + return LocalFhirNpmPackageMetadata( + getString("name"), + getString("version"), + optString("canonical"), + dependencies?.toList() ?: emptyList(), + packageFolder, + ) + } + } +} diff --git a/knowledge/src/main/java/com/google/android/fhir/knowledge/npm/OkHttpPackageDownloader.kt b/knowledge/src/main/java/com/google/android/fhir/knowledge/npm/OkHttpPackageDownloader.kt new file mode 100644 index 0000000000..181f3aa56f --- /dev/null +++ b/knowledge/src/main/java/com/google/android/fhir/knowledge/npm/OkHttpPackageDownloader.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.knowledge.npm + +import com.google.android.fhir.knowledge.FhirNpmPackage +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.util.zip.GZIPInputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.ResponseBody +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream + +/** Downloads Npm package from the provided package server using OkHttp library. */ +internal class OkHttpPackageDownloader( + private val npmFileManager: NpmFileManager, +) : PackageDownloader { + + val client = OkHttpClient() + + @Throws(IOException::class) + override suspend fun downloadPackage( + fhirNpmPackage: FhirNpmPackage, + packageServerUrl: String, + ): LocalFhirNpmPackageMetadata { + return withContext(Dispatchers.IO) { + val packageName = fhirNpmPackage.name + val version = fhirNpmPackage.version + val url = "$packageServerUrl$packageName/$version" + + val request = Request.Builder().url(url).get().build() + + val response = client.newCall(request).execute() + + if (!response.isSuccessful) { + throw IOException("Unexpected code $response") + } + val packageFolder = + npmFileManager.getPackageFolder(fhirNpmPackage.name, fhirNpmPackage.version) + + response.body?.use { responseBody -> + packageFolder.mkdirs() + val tgzFile = File(packageFolder, "$packageName-$version.tgz") + saveResponseToFile(responseBody, tgzFile) + + extractTgzFile(tgzFile, packageFolder) + + tgzFile.delete() + } + npmFileManager.getPackage(fhirNpmPackage.name, fhirNpmPackage.version) + } + } + + private fun saveResponseToFile(responseBody: ResponseBody, file: File) { + FileOutputStream(file).use { fileOutputStream -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + while (responseBody.byteStream().read(buffer).also { bytesRead = it } != -1) { + fileOutputStream.write(buffer, 0, bytesRead) + } + } + } + + private fun extractTgzFile(tgzFile: File, outputFolder: File) { + FileInputStream(tgzFile).use { fileInputStream -> + outputFolder.mkdirs() + GZIPInputStream(fileInputStream).use { gzipInputStream -> + TarArchiveInputStream(gzipInputStream).use { tarInputStream -> + var entry: TarArchiveEntry? = tarInputStream.nextTarEntry + while (entry != null) { + val outputFile = File(outputFolder, entry.name) + + if (entry.isDirectory) { + outputFile.mkdirs() + } else { + outputFile.parentFile?.mkdirs() + + val outputFileStream = FileOutputStream(outputFile) + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + while (tarInputStream.read(buffer).also { bytesRead = it } != -1) { + outputFileStream.write(buffer, 0, bytesRead) + } + outputFileStream.close() + } + + entry = tarInputStream.nextTarEntry + } + } + } + } + } +} diff --git a/knowledge/src/main/java/com/google/android/fhir/knowledge/npm/PackageDownloader.kt b/knowledge/src/main/java/com/google/android/fhir/knowledge/npm/PackageDownloader.kt new file mode 100644 index 0000000000..70a44d851f --- /dev/null +++ b/knowledge/src/main/java/com/google/android/fhir/knowledge/npm/PackageDownloader.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.knowledge.npm + +import com.google.android.fhir.knowledge.FhirNpmPackage + +/** Downloads Npm package from the provided package server. */ +fun interface PackageDownloader { + + /** Downloads the [fhirNpmPackage] from the [packageServerUrl]. */ + suspend fun downloadPackage( + fhirNpmPackage: FhirNpmPackage, + packageServerUrl: String, + ): LocalFhirNpmPackageMetadata +} diff --git a/knowledge/src/test/java/com/google/android/fhir/knowledge/KnowledgeManagerNpmTest.kt b/knowledge/src/test/java/com/google/android/fhir/knowledge/KnowledgeManagerNpmTest.kt new file mode 100644 index 0000000000..ac07a0a314 --- /dev/null +++ b/knowledge/src/test/java/com/google/android/fhir/knowledge/KnowledgeManagerNpmTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.knowledge + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.google.android.fhir.knowledge.db.impl.KnowledgeDatabase +import com.google.android.fhir.knowledge.npm.LocalFhirNpmPackageMetadata +import com.google.android.fhir.knowledge.npm.NpmFileManager +import com.google.android.fhir.knowledge.npm.PackageDownloader +import com.google.common.truth.Truth.assertThat +import java.io.File +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class KnowledgeManagerNpmTest { + + private val downloadedDependencies = mutableSetOf() + private val fakePackageDownloader: PackageDownloader = + PackageDownloader { fhirNpmPackage: FhirNpmPackage, _ -> + downloadedDependencies.add(fhirNpmPackage) + NPM_CACHE_MAP.getValue(fhirNpmPackage) + } + + private val context: Context = ApplicationProvider.getApplicationContext() + private val mockNpmFileManager = mock() + private val knowledgeDb = + Room.inMemoryDatabaseBuilder(context, KnowledgeDatabase::class.java).build() + + private val knowledgeManager = + KnowledgeManager( + knowledgeDb, + context.dataDir, + npmFileManager = mockNpmFileManager, + packageDownloader = fakePackageDownloader, + ) + + @Test + fun install_withDependencies() = runTest { + whenever(mockNpmFileManager.containsPackage(any(), any())).thenReturn(false) + + knowledgeManager.install(DEP1) + + assertThat(downloadedDependencies).containsExactly(DEP1, DEP2, DEP3) + } + + @Test + fun install_alreadyCached() = runTest { + whenever(mockNpmFileManager.containsPackage(any(), any())).thenReturn(true) + whenever(mockNpmFileManager.getPackage(DEP1.name, DEP1.version)).thenReturn(NPM1) + whenever(mockNpmFileManager.getPackage(DEP2.name, DEP2.version)).thenReturn(NPM2) + whenever(mockNpmFileManager.getPackage(DEP3.name, DEP3.version)).thenReturn(NPM3) + + knowledgeManager.install(DEP1) + + assertThat(downloadedDependencies).isEmpty() + } + + @Test + fun install_someCached() = runTest { + whenever(mockNpmFileManager.containsPackage(DEP1.name, DEP1.version)).thenReturn(false) + whenever(mockNpmFileManager.containsPackage(DEP2.name, DEP2.version)).thenReturn(true) + whenever(mockNpmFileManager.containsPackage(DEP3.name, DEP3.version)).thenReturn(true) + whenever(mockNpmFileManager.getPackage(DEP2.name, DEP2.version)).thenReturn(NPM2) + whenever(mockNpmFileManager.getPackage(DEP3.name, DEP3.version)).thenReturn(NPM3) + + knowledgeManager.install(DEP1, DEP2) + + assertThat(downloadedDependencies).containsExactly(DEP1) + } + + private companion object { + val DEP1 = FhirNpmPackage("package1", "version") + val DEP2 = FhirNpmPackage("package2", "version") + val DEP3 = FhirNpmPackage("package3", "version") + val NPM1 = + LocalFhirNpmPackageMetadata(DEP1.name, DEP1.version, null, listOf(DEP2), File("/fakePath")) + val NPM2 = + LocalFhirNpmPackageMetadata(DEP2.name, DEP2.version, null, listOf(DEP3), File("/fakePath")) + val NPM3 = + LocalFhirNpmPackageMetadata(DEP3.name, DEP1.version, null, emptyList(), File("/fakePath")) + val NPM_CACHE_MAP = mapOf(DEP1 to NPM1, DEP2 to NPM2, DEP3 to NPM3) + } +} diff --git a/knowledge/src/test/java/com/google/android/fhir/knowledge/KnowledgeManagerTest.kt b/knowledge/src/test/java/com/google/android/fhir/knowledge/KnowledgeManagerTest.kt index c31e04814c..f27816fe27 100644 --- a/knowledge/src/test/java/com/google/android/fhir/knowledge/KnowledgeManagerTest.kt +++ b/knowledge/src/test/java/com/google/android/fhir/knowledge/KnowledgeManagerTest.kt @@ -22,9 +22,10 @@ import androidx.test.core.app.ApplicationProvider import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import com.google.android.fhir.knowledge.db.impl.KnowledgeDatabase +import com.google.android.fhir.knowledge.npm.LocalFhirNpmPackageMetadata +import com.google.android.fhir.knowledge.npm.NpmFileManager import com.google.common.truth.Truth.assertThat import java.io.File -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.Library import org.junit.After @@ -32,15 +33,30 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) internal class KnowledgeManagerTest { private val context: Context = ApplicationProvider.getApplicationContext() + private val fhirNpmPackage = FhirNpmPackage("anc-cds", "0.3.0", "http://url.com") + private val dataFolder = File(javaClass.getResource("/anc-cds")!!.file) + private val jsonParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() private val knowledgeDb = Room.inMemoryDatabaseBuilder(context, KnowledgeDatabase::class.java).build() - private val knowledgeManager = KnowledgeManager(knowledgeDb) - private val implementationGuide = ImplementationGuide("anc-cds", "0.3.0", "http://url.com") - private val dataFolder = File(javaClass.getResource("/anc-cds")!!.file) + private val npmFileManager = NpmFileManager(context.dataDir) + private val knowledgeManager = + KnowledgeManager( + knowledgeDb, + context.dataDir, + npmFileManager = npmFileManager, + packageDownloader = { fhirPackage, _ -> + LocalFhirNpmPackageMetadata( + fhirPackage.name, + fhirPackage.version, + fhirPackage.canonical, + emptyList(), + dataFolder, + ) + }, + ) @After fun closeDb() { @@ -49,7 +65,7 @@ internal class KnowledgeManagerTest { @Test fun `importing IG creates entries in DB`() = runTest { - knowledgeManager.install(implementationGuide, dataFolder) + knowledgeManager.install(fhirNpmPackage, dataFolder) val implementationGuideId = knowledgeDb.knowledgeDao().getImplementationGuide("anc-cds", "0.3.0")!!.implementationGuideId @@ -67,9 +83,9 @@ internal class KnowledgeManagerTest { val igRoot = File(dataFolder.parentFile, "anc-cds.copy") igRoot.deleteOnExit() dataFolder.copyRecursively(igRoot) - knowledgeManager.install(implementationGuide, igRoot) + knowledgeManager.install(fhirNpmPackage, igRoot) - knowledgeManager.delete(implementationGuide) + knowledgeManager.delete(fhirNpmPackage) assertThat(knowledgeDb.knowledgeDao().getImplementationGuides()).isEmpty() assertThat(igRoot.exists()).isFalse() @@ -77,7 +93,7 @@ internal class KnowledgeManagerTest { @Test fun `imported entries are readable`() = runTest { - knowledgeManager.install(implementationGuide, dataFolder) + knowledgeManager.install(fhirNpmPackage, dataFolder) assertThat(knowledgeManager.loadResources(resourceType = "Library", name = "WHOCommon")) .isNotNull() @@ -119,7 +135,24 @@ internal class KnowledgeManagerTest { assertThat(resources).hasSize(2) } - private val jsonParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + fun `installing from npmPackageManager`() = runTest { + knowledgeManager.install(fhirNpmPackage) + + assertThat(knowledgeManager.loadResources(resourceType = "Library", name = "WHOCommon")) + .isNotNull() + assertThat(knowledgeManager.loadResources(resourceType = "Library", url = "FHIRCommon")) + .isNotNull() + assertThat(knowledgeManager.loadResources(resourceType = "Measure")).hasSize(1) + assertThat( + knowledgeManager.loadResources( + resourceType = "Measure", + url = "http://fhir.org/guides/who/anc-cds/Measure/ANCIND01", + ), + ) + .isNotEmpty() + assertThat(knowledgeManager.loadResources(resourceType = "Measure", url = "Measure/ANCIND01")) + .isNotNull() + } private fun writeToFile(library: Library): File { return File(context.filesDir, library.name).apply { diff --git a/knowledge/src/test/java/com/google/android/fhir/knowledge/npm/NpmFileManagerTest.kt b/knowledge/src/test/java/com/google/android/fhir/knowledge/npm/NpmFileManagerTest.kt new file mode 100644 index 0000000000..e11c5ded14 --- /dev/null +++ b/knowledge/src/test/java/com/google/android/fhir/knowledge/npm/NpmFileManagerTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.knowledge.npm + +import com.google.android.fhir.knowledge.FhirNpmPackage +import com.google.common.truth.Truth.assertThat +import java.io.File +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class NpmFileManagerTest { + private val testDataFolder = File(javaClass.getResource("/cache_manager")!!.file) + private val npmFileManager: NpmFileManager = NpmFileManager(testDataFolder) + + @Test + fun getPackageFolder() { + val packageFolder = npmFileManager.getPackageFolder(PACKAGE_ID, VERSION) + + assertThat(packageFolder.absolutePath) + .isEqualTo("${testDataFolder.absolutePath}/$PACKAGE_ID#$VERSION") + } + + @Test + fun getPackage() = runTest { + val npmPackage = npmFileManager.getPackage(PACKAGE_ID, VERSION) + + assertThat(npmPackage.packageId).isEqualTo(PACKAGE_ID) + assertThat(npmPackage.version).isEqualTo(VERSION) + assertThat(npmPackage.dependencies).isEqualTo(DEPENDENCIES) + assertThat(npmPackage.rootDirectory) + .isEqualTo(File(testDataFolder, "$PACKAGE_ID#$VERSION/package")) + } + + @Test + fun containsPackage_notFound() = runTest { + assertThat(npmFileManager.containsPackage(PACKAGE_ID, MISSING_VERSION)).isFalse() + } + + companion object { + const val PACKAGE_ID = "test-package" + const val VERSION = "13.3.7" + const val MISSING_VERSION = "13.3.8" + val DEPENDENCIES = + listOf( + FhirNpmPackage("hl7.fhir.r4.core", "4.0.1"), + ) + } +} diff --git a/knowledge/src/test/java/com/google/android/fhir/knowledge/npm/OkHttpPackageDownloaderTest.kt b/knowledge/src/test/java/com/google/android/fhir/knowledge/npm/OkHttpPackageDownloaderTest.kt new file mode 100644 index 0000000000..40be5bde93 --- /dev/null +++ b/knowledge/src/test/java/com/google/android/fhir/knowledge/npm/OkHttpPackageDownloaderTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.knowledge.npm + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.android.fhir.knowledge.FhirNpmPackage +import com.google.common.truth.Truth.assertThat +import java.io.IOException +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class OkHttpPackageDownloaderTest { + + @get:Rule val mockWebServer = MockWebServer() + + private val rootCacheFolder = ApplicationProvider.getApplicationContext().dataDir + private val npmFileManager = NpmFileManager(rootCacheFolder) + private var downloader = OkHttpPackageDownloader(npmFileManager) + + @Test + fun downloadPackage_returnsNpmPackage() = runTest { + val packageServerUrl = mockWebServer.url("/packages/$PACKAGE_ID#$VERSION").toString() + val fhirNpmPackage = FhirNpmPackage(PACKAGE_ID, VERSION) + val testFileBytes = javaClass.getResourceAsStream("/okhttp_downloader/package.tgz")!! + val testFileBuffer = Buffer().readFrom(testFileBytes) + val responseBody = MockResponse().setResponseCode(200).setBody(testFileBuffer) + mockWebServer.enqueue(responseBody) + + val npmPackage = downloader.downloadPackage(fhirNpmPackage, packageServerUrl) + + assertThat(npmPackage.packageId).isEqualTo(PACKAGE_ID) + assertThat(npmPackage.version).isEqualTo(VERSION) + assertThat(npmPackage.dependencies).isEqualTo(DEPENDENCIES) + } + + @Test(expected = IOException::class) + fun testDownloadPackage_serverError_throwsException() = runTest { + val packageServerUrl = mockWebServer.url("/packages/$PACKAGE_ID#$VERSION").toString() + val fhirNpmPackage = FhirNpmPackage(PACKAGE_ID, VERSION) + + val responseBody = MockResponse().setResponseCode(500) + + mockWebServer.enqueue(responseBody) + + downloader.downloadPackage(fhirNpmPackage, packageServerUrl) + } + + companion object { + const val PACKAGE_ID = "test-package" + const val VERSION = "13.3.7" + val DEPENDENCIES = + listOf( + FhirNpmPackage("hl7.fhir.r4.core", "4.0.1"), + FhirNpmPackage("hl7.terminology.r4", "5.0.0"), + FhirNpmPackage("hl7.fhir.fr.core", "1.1.0"), + ) + } +} diff --git a/knowledge/testdata/anc-cds/package.json b/knowledge/testdata/anc-cds/package.json new file mode 100644 index 0000000000..34a15060db --- /dev/null +++ b/knowledge/testdata/anc-cds/package.json @@ -0,0 +1,9 @@ +{ + "name": "anc-cds", + "version": "0.0.3", + "type": "IG", + "title": "anc-cds", + "fhirVersions": [ + "4.0.1" + ] +} \ No newline at end of file diff --git a/knowledge/testdata/cache_manager/test-package#13.3.7/package/package.json b/knowledge/testdata/cache_manager/test-package#13.3.7/package/package.json new file mode 100644 index 0000000000..6385ad8800 --- /dev/null +++ b/knowledge/testdata/cache_manager/test-package#13.3.7/package/package.json @@ -0,0 +1,13 @@ +{ + "name": "test-package", + "version": "13.3.7", + "type": "IG", + "canonical": "https://google.com/test-package", + "title": "Test package", + "fhirVersions": [ + "4.0.1" + ], + "dependencies": { + "hl7.fhir.r4.core": "4.0.1" + } +} \ No newline at end of file diff --git a/knowledge/testdata/okhttp_downloader/package.tgz b/knowledge/testdata/okhttp_downloader/package.tgz new file mode 100644 index 0000000000000000000000000000000000000000..956c296c5db65cecb7d231698edd8cf57041677d GIT binary patch literal 455 zcmV;&0XY62iwFRczI`v6iKZrUxM;7z2^$0a3~XO9|-W z1B?PpDdAjTiUmMeP=q3Ie}Ggc71Zl8l(SHE)XMp*Z;vU z@BPmhVi916Y-;3(`+t1@=O6zA@V2#Pni}DGa7sTj{{!J1rQru4=K22>{B`~#jwnnC z7F^^wpXdL7$msv+UH{7H|3A%t%F_@2?}8CPQ#Z1YAMXE$1pu*;wTe$b>{aWJpTC6o zsLR|b*J^9JBqoqRF@*iL=@bJNE zDex|Jv(oNzsM`j`RfvTsg$Uv}oL~2%rD~K}DpP3Hi5>>gsuHkU4jm13U0+~Wc=Rgp x%GKJ~%9h)p>m)2l&|68r$Bld014A4HA^*5|{O&h4Gcz+Y4Np|An`HnJ003eh>BRs5 literal 0 HcmV?d00001 diff --git a/workflow/benchmark/src/androidTest/java/com/google/android/fhir/workflow/benchmark/F_CqlEvaluatorBenchmark.kt b/workflow/benchmark/src/androidTest/java/com/google/android/fhir/workflow/benchmark/F_CqlEvaluatorBenchmark.kt index e4f20df164..0cddf1010e 100644 --- a/workflow/benchmark/src/androidTest/java/com/google/android/fhir/workflow/benchmark/F_CqlEvaluatorBenchmark.kt +++ b/workflow/benchmark/src/androidTest/java/com/google/android/fhir/workflow/benchmark/F_CqlEvaluatorBenchmark.kt @@ -26,7 +26,7 @@ import ca.uhn.fhir.context.FhirVersionEnum import com.google.android.fhir.FhirEngineConfiguration import com.google.android.fhir.FhirEngineProvider import com.google.android.fhir.knowledge.KnowledgeManager -import com.google.android.fhir.workflow.FhirOperatorBuilder +import com.google.android.fhir.workflow.FhirOperator import com.google.common.truth.Truth.assertThat import java.io.File import java.io.InputStream @@ -75,10 +75,10 @@ class F_CqlEvaluatorBenchmark { ) } - FhirOperatorBuilder(context) - .withFhirContext(fhirContext) - .withFhirEngine(fhirEngine) - .withIgManager(knowledgeManager) + FhirOperator.Builder(context) + .fhirContext(fhirContext) + .fhirEngine(fhirEngine) + .knowledgeManager(knowledgeManager) .build() } diff --git a/workflow/build.gradle.kts b/workflow/build.gradle.kts index d08dc4245a..85fb74cc8c 100644 --- a/workflow/build.gradle.kts +++ b/workflow/build.gradle.kts @@ -84,6 +84,7 @@ configurations { } dependencies { + testImplementation(project(mapOf("path" to ":knowledge"))) coreLibraryDesugaring(Dependencies.desugarJdkLibs) androidTestImplementation(Dependencies.AndroidxTest.core) @@ -134,7 +135,7 @@ dependencies { implementation(Dependencies.Kotlin.stdlib) implementation(Dependencies.xerces) implementation(Dependencies.androidFhirEngine) { exclude(module = "truth") } - implementation(Dependencies.androidFhirKnowledge) + implementation(project(":knowledge")) testImplementation(Dependencies.AndroidxTest.core) testImplementation(Dependencies.jsonAssert) diff --git a/workflow/src/androidTest/java/com/google/android/fhir/workflow/FhirOperatorLibraryEvaluateTest.kt b/workflow/src/androidTest/java/com/google/android/fhir/workflow/FhirOperatorLibraryEvaluateTest.kt index 7ce5420044..58a1b5e5eb 100644 --- a/workflow/src/androidTest/java/com/google/android/fhir/workflow/FhirOperatorLibraryEvaluateTest.kt +++ b/workflow/src/androidTest/java/com/google/android/fhir/workflow/FhirOperatorLibraryEvaluateTest.kt @@ -70,10 +70,10 @@ class FhirOperatorLibraryEvaluateTest { fun setUp() = runBlocking { fhirEngine = FhirEngineProvider.getInstance(context) fhirOperator = - FhirOperatorBuilder(context) - .withFhirContext(fhirContext) - .withFhirEngine(fhirEngine) - .withIgManager(knowledgeManager) + FhirOperator.Builder(context) + .fhirContext(fhirContext) + .fhirEngine(fhirEngine) + .knowledgeManager(knowledgeManager) .build() } diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt b/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt index 13040fd6c5..84d497ee2e 100644 --- a/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt +++ b/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperator.kt @@ -16,10 +16,12 @@ package com.google.android.fhir.workflow +import android.content.Context import androidx.annotation.WorkerThread import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import com.google.android.fhir.FhirEngine +import com.google.android.fhir.FhirEngineProvider import com.google.android.fhir.knowledge.KnowledgeManager import java.util.function.Supplier import org.hl7.fhir.instance.model.api.IBaseParameters @@ -363,4 +365,26 @@ internal constructor( /* terminologyEndpoint= */ null, ) as IBaseResource } + + class Builder(private val applicationContext: Context) { + private var fhirContext: FhirContext? = null + private var fhirEngine: FhirEngine? = null + private var knowledgeManager: KnowledgeManager? = null + + fun fhirEngine(fhirEngine: FhirEngine) = apply { this.fhirEngine = fhirEngine } + + fun knowledgeManager(knowledgeManager: KnowledgeManager) = apply { + this.knowledgeManager = knowledgeManager + } + + fun fhirContext(fhirContext: FhirContext) = apply { this.fhirContext = fhirContext } + + fun build(): FhirOperator { + return FhirOperator( + fhirContext ?: FhirContext(FhirVersionEnum.R4), + fhirEngine ?: FhirEngineProvider.getInstance(applicationContext), + knowledgeManager ?: KnowledgeManager.create(applicationContext), + ) + } + } } diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperatorBuilder.kt b/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperatorBuilder.kt deleted file mode 100644 index 0130c23ba1..0000000000 --- a/workflow/src/main/java/com/google/android/fhir/workflow/FhirOperatorBuilder.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2022-2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.fhir.workflow - -import android.content.Context -import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.context.FhirVersionEnum -import com.google.android.fhir.FhirEngine -import com.google.android.fhir.FhirEngineProvider -import com.google.android.fhir.knowledge.ImplementationGuide -import com.google.android.fhir.knowledge.KnowledgeManager - -class FhirOperatorBuilder(private val applicationContext: Context) { - private var fhirContext: FhirContext? = null - private var fhirEngine: FhirEngine? = null - private var implementationGuides: List = emptyList() - private var knowledgeManager: KnowledgeManager? = null - - fun withFhirEngine(fhirEngine: FhirEngine): FhirOperatorBuilder { - this.fhirEngine = fhirEngine - return this - } - - fun withIgManager(knowledgeManager: KnowledgeManager): FhirOperatorBuilder { - this.knowledgeManager = knowledgeManager - return this - } - - fun withFhirContext(fhirContext: FhirContext): FhirOperatorBuilder { - this.fhirContext = fhirContext - return this - } - - fun withImplementationGuides( - vararg implementationGuides: ImplementationGuide, - ): FhirOperatorBuilder { - this.implementationGuides = implementationGuides.toList() - return this - } - - fun build(): FhirOperator { - return FhirOperator( - fhirContext ?: FhirContext(FhirVersionEnum.R4), - fhirEngine ?: FhirEngineProvider.getInstance(applicationContext), - knowledgeManager ?: KnowledgeManager.create(applicationContext), - ) - } -} diff --git a/workflow/src/test/java/com/google/android/fhir/workflow/FhirOperatorTest.kt b/workflow/src/test/java/com/google/android/fhir/workflow/FhirOperatorTest.kt index 5143cad38d..2901b5436f 100644 --- a/workflow/src/test/java/com/google/android/fhir/workflow/FhirOperatorTest.kt +++ b/workflow/src/test/java/com/google/android/fhir/workflow/FhirOperatorTest.kt @@ -21,7 +21,7 @@ import androidx.test.core.app.ApplicationProvider import ca.uhn.fhir.context.FhirContext import com.google.android.fhir.FhirEngine import com.google.android.fhir.FhirEngineProvider -import com.google.android.fhir.knowledge.ImplementationGuide +import com.google.android.fhir.knowledge.FhirNpmPackage import com.google.android.fhir.knowledge.KnowledgeManager import com.google.android.fhir.workflow.testing.CqlBuilder import com.google.android.fhir.workflow.testing.FhirEngineProviderTestRule @@ -67,7 +67,7 @@ class FhirOperatorTest { // Installing ANC CDS to the IGManager val rootDirectory = File(javaClass.getResource("/anc-cds")!!.file) knowledgeManager.install( - ImplementationGuide( + FhirNpmPackage( "com.google.android.fhir", "1.0.0", "http://github.com/google/android-fhir", From c2b2e84eb8e5d47ad1d5be85383829065a9570f8 Mon Sep 17 00:00:00 2001 From: Omar Ismail <44980219+omarismail94@users.noreply.github.com> Date: Thu, 5 Oct 2023 14:06:30 +0100 Subject: [PATCH 3/9] Refactor syncUpload Method to improve flow and error handling (#2173) * Clean up SyncJobStatus and add FhirSynchronizer test * remove changes in MAVM * use FetchProgress to track upload progress * add tests * spotless * fix tests * take care of continue sync * simplify simplify simplify * update kdoc * simplify further --- .../android/fhir/db/impl/DatabaseImplTest.kt | 33 ++++--- .../com/google/android/fhir/FhirEngine.kt | 18 +++- .../android/fhir/impl/FhirEngineImpl.kt | 36 ++++++- .../android/fhir/sync/FhirSynchronizer.kt | 27 ++--- .../fhir/sync/upload/LocalChangeFetcher.kt | 14 +-- .../fhir/sync/upload/ResourceConsolidator.kt | 25 +++-- .../android/fhir/sync/upload/Uploader.kt | 98 +++++++++---------- .../google/android/fhir/testing/Utilities.kt | 13 ++- .../android/fhir/impl/FhirEngineImplTest.kt | 50 ++++++++-- .../android/fhir/sync/FhirSynchronizerTest.kt | 35 +++---- .../AllChangesLocalChangeFetcherTest.kt | 6 +- .../{UploaderImplTest.kt => UploaderTest.kt} | 39 ++------ 12 files changed, 221 insertions(+), 173 deletions(-) rename engine/src/test/java/com/google/android/fhir/sync/upload/{UploaderImplTest.kt => UploaderTest.kt} (77%) diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt index 2809b79bad..7ea0240a23 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt @@ -40,6 +40,7 @@ import com.google.android.fhir.search.has import com.google.android.fhir.search.include import com.google.android.fhir.search.revInclude import com.google.android.fhir.sync.upload.LocalChangesFetchMode +import com.google.android.fhir.sync.upload.UploadSyncResult import com.google.android.fhir.testing.assertJsonArrayEqualsIgnoringOrder import com.google.android.fhir.testing.assertResourceEquals import com.google.android.fhir.testing.readFromFile @@ -49,7 +50,7 @@ import com.google.common.truth.Truth.assertThat import java.math.BigDecimal import java.time.Instant import java.util.Date -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.Address import org.hl7.fhir.r4.model.CarePlan @@ -528,19 +529,23 @@ class DatabaseImplTest { database.insert(patient) // Delete the patient created in setup as we only want to upload the patient in this test database.deleteUpdates(listOf(TEST_PATIENT_1)) - services.fhirEngine.syncUpload(LocalChangesFetchMode.AllChanges) { - it - .first { it.resourceId == "remote-patient-3" } - .let { - flowOf( - it.token to - Patient().apply { - id = it.resourceId - meta = remoteMeta - }, - ) - } - } + services.fhirEngine + .syncUpload(LocalChangesFetchMode.AllChanges) { + it + .first { it.resourceId == "remote-patient-3" } + .let { + UploadSyncResult.Success( + it.token, + listOf( + Patient().apply { + id = it.resourceId + meta = remoteMeta + }, + ), + ) + } + } + .collect() val selectedEntity = database.selectEntity(ResourceType.Patient, "remote-patient-3") assertThat(selectedEntity.versionId).isEqualTo(remoteMeta.versionId) assertThat(selectedEntity.lastUpdatedRemote).isEqualTo(remoteMeta.lastUpdated.toInstant()) diff --git a/engine/src/main/java/com/google/android/fhir/FhirEngine.kt b/engine/src/main/java/com/google/android/fhir/FhirEngine.kt index 0b9a31b4fb..cc0a971721 100644 --- a/engine/src/main/java/com/google/android/fhir/FhirEngine.kt +++ b/engine/src/main/java/com/google/android/fhir/FhirEngine.kt @@ -20,6 +20,8 @@ import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.search.Search import com.google.android.fhir.sync.ConflictResolver import com.google.android.fhir.sync.upload.LocalChangesFetchMode +import com.google.android.fhir.sync.upload.SyncUploadProgress +import com.google.android.fhir.sync.upload.UploadSyncResult import java.time.OffsetDateTime import kotlinx.coroutines.flow.Flow import org.hl7.fhir.r4.model.Resource @@ -49,14 +51,20 @@ interface FhirEngine { suspend fun search(search: Search): List> /** - * Synchronizes the [upload] result in the database. [upload] operation may result in multiple - * calls to the server to upload the data. Result of each call will be emitted by [upload] and the - * api caller should [Flow.collect] it. + * Synchronizes the upload results in the database. + * + * The [upload] function may initiate multiple server calls. Each call's result can then be used + * to emit [UploadSyncResult]. The caller should collect these results using [Flow.collect]. + * + * @param localChangesFetchMode Specifies the mode to fetch local changes. + * @param upload A suspend function that takes a list of [LocalChange] and returns an + * [UploadSyncResult]. + * @return A [Flow] that emits the progress of the synchronization process. */ suspend fun syncUpload( localChangesFetchMode: LocalChangesFetchMode, - upload: (suspend (List) -> Flow>), - ) + upload: (suspend (List) -> UploadSyncResult), + ): Flow /** * Synchronizes the [download] result in the database. The database will be updated to reflect the diff --git a/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt b/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt index adf2611487..c1519cd4a8 100644 --- a/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt +++ b/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt @@ -20,7 +20,6 @@ import android.content.Context import com.google.android.fhir.DatastoreUtil import com.google.android.fhir.FhirEngine import com.google.android.fhir.LocalChange -import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.SearchResult import com.google.android.fhir.db.Database import com.google.android.fhir.logicalId @@ -32,8 +31,11 @@ import com.google.android.fhir.sync.Resolved import com.google.android.fhir.sync.upload.DefaultResourceConsolidator import com.google.android.fhir.sync.upload.LocalChangeFetcherFactory import com.google.android.fhir.sync.upload.LocalChangesFetchMode +import com.google.android.fhir.sync.upload.SyncUploadProgress +import com.google.android.fhir.sync.upload.UploadSyncResult import java.time.OffsetDateTime import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -125,13 +127,37 @@ internal class FhirEngineImpl(private val database: Database, private val contex override suspend fun syncUpload( localChangesFetchMode: LocalChangesFetchMode, - upload: suspend (List) -> Flow>, - ) { + upload: (suspend (List) -> UploadSyncResult), + ): Flow = flow { val resourceConsolidator = DefaultResourceConsolidator(database) val localChangeFetcher = LocalChangeFetcherFactory.byMode(localChangesFetchMode, database) + + emit( + SyncUploadProgress( + remaining = localChangeFetcher.total, + initialTotal = localChangeFetcher.total, + ), + ) + while (localChangeFetcher.hasNext()) { - upload(localChangeFetcher.next()).collect { - resourceConsolidator.consolidate(it.first, it.second) + val localChanges = localChangeFetcher.next() + val uploadSyncResult = upload(localChanges) + + resourceConsolidator.consolidate(uploadSyncResult) + when (uploadSyncResult) { + is UploadSyncResult.Success -> emit(localChangeFetcher.getProgress()) + is UploadSyncResult.Failure -> { + with(localChangeFetcher.getProgress()) { + emit( + SyncUploadProgress( + remaining = remaining, + initialTotal = initialTotal, + uploadError = uploadSyncResult.syncError, + ), + ) + } + break + } } } } diff --git a/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt b/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt index c878634010..acac045a14 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt @@ -22,7 +22,6 @@ import com.google.android.fhir.FhirEngine import com.google.android.fhir.sync.download.DownloadState import com.google.android.fhir.sync.download.Downloader import com.google.android.fhir.sync.upload.LocalChangesFetchMode -import com.google.android.fhir.sync.upload.UploadState import com.google.android.fhir.sync.upload.Uploader import java.time.OffsetDateTime import kotlinx.coroutines.flow.MutableSharedFlow @@ -120,23 +119,17 @@ internal class FhirSynchronizer( private suspend fun upload(): SyncResult { val exceptions = mutableListOf() val localChangesFetchMode = LocalChangesFetchMode.AllChanges - fhirEngine.syncUpload(localChangesFetchMode) { list -> - flow { - uploader.upload(list).collect { result -> - when (result) { - is UploadState.Started -> - setSyncState(SyncJobStatus.InProgress(SyncOperation.UPLOAD, result.total)) - is UploadState.Success -> - emit(result.localChangeToken to result.resource).also { - setSyncState( - SyncJobStatus.InProgress(SyncOperation.UPLOAD, result.total, result.completed), - ) - } - is UploadState.Failure -> exceptions.add(result.syncError) - } - } - } + fhirEngine.syncUpload(localChangesFetchMode, uploader::upload).collect { progress -> + progress.uploadError?.let { exceptions.add(it) } + ?: setSyncState( + SyncJobStatus.InProgress( + SyncOperation.UPLOAD, + progress.initialTotal, + progress.initialTotal - progress.remaining, + ), + ) } + return if (exceptions.isEmpty()) { SyncResult.Success() } else { diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/LocalChangeFetcher.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/LocalChangeFetcher.kt index 34f8eee4d4..8b337963e7 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/LocalChangeFetcher.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/LocalChangeFetcher.kt @@ -18,6 +18,7 @@ package com.google.android.fhir.sync.upload import com.google.android.fhir.LocalChange import com.google.android.fhir.db.Database +import com.google.android.fhir.sync.ResourceSyncException import kotlin.properties.Delegates /** @@ -40,15 +41,16 @@ internal interface LocalChangeFetcher { suspend fun next(): List /** - * Returns [FetchProgress], which contains the remaining changes left to upload and the initial - * total to upload. + * Returns [SyncUploadProgress], which contains the remaining changes left to upload and the + * initial total to upload. */ - suspend fun getProgress(): FetchProgress + suspend fun getProgress(): SyncUploadProgress } -data class FetchProgress( +data class SyncUploadProgress( val remaining: Int, val initialTotal: Int, + val uploadError: ResourceSyncException? = null, ) internal class AllChangesLocalChangeFetcher( @@ -65,8 +67,8 @@ internal class AllChangesLocalChangeFetcher( override suspend fun next(): List = database.getAllLocalChanges() - override suspend fun getProgress(): FetchProgress = - FetchProgress(database.getLocalChangesCount(), total) + override suspend fun getProgress(): SyncUploadProgress = + SyncUploadProgress(database.getLocalChangesCount(), total) } /** Represents the mode in which local changes should be fetched. */ diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt index d8f19fc5a2..f5f3c8c7f7 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt @@ -16,7 +16,6 @@ package com.google.android.fhir.sync.upload -import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.db.Database import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Resource @@ -36,19 +35,29 @@ import timber.log.Timber internal fun interface ResourceConsolidator { /** Consolidates the local change token with the provided response from the FHIR server. */ - suspend fun consolidate(localChangeToken: LocalChangeToken, response: Resource) + suspend fun consolidate(uploadSyncResult: UploadSyncResult) } /** Default implementation of [ResourceConsolidator] that uses the database to aid consolidation. */ internal class DefaultResourceConsolidator(private val database: Database) : ResourceConsolidator { - override suspend fun consolidate(localChangeToken: LocalChangeToken, response: Resource) { - database.deleteUpdates(localChangeToken) - when (response) { - is Bundle -> updateVersionIdAndLastUpdated(response) - else -> updateVersionIdAndLastUpdated(response) + override suspend fun consolidate(uploadSyncResult: UploadSyncResult) = + when (uploadSyncResult) { + is UploadSyncResult.Success -> { + database.deleteUpdates(uploadSyncResult.localChangeToken) + uploadSyncResult.resources.forEach { + when (it) { + is Bundle -> updateVersionIdAndLastUpdated(it) + else -> updateVersionIdAndLastUpdated(it) + } + } + } + is UploadSyncResult.Failure -> { + /* For now, do nothing (we do not delete the local changes from the database as they were + not uploaded successfully. In the future, add consolidation required if upload fails. + */ + } } - } private suspend fun updateVersionIdAndLastUpdated(bundle: Bundle) { when (bundle.type) { diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/Uploader.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/Uploader.kt index 0e5653371b..b5a8ee66d8 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/Uploader.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/Uploader.kt @@ -22,8 +22,7 @@ import com.google.android.fhir.sync.DataSource import com.google.android.fhir.sync.ResourceSyncException import com.google.android.fhir.sync.upload.patch.PerResourcePatchGenerator import com.google.android.fhir.sync.upload.request.TransactionBundleGenerator -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow +import com.google.android.fhir.sync.upload.request.UploadRequest import org.hl7.fhir.exceptions.FHIRException import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.OperationOutcome @@ -39,70 +38,67 @@ import timber.log.Timber * 4. processing the responses from the server and consolidate any changes (i.e. updates resource * IDs). */ -internal class Uploader( - private val dataSource: DataSource, -) { +internal class Uploader(private val dataSource: DataSource) { private val patchGenerator = PerResourcePatchGenerator private val requestGenerator = TransactionBundleGenerator.getDefault() - suspend fun upload(localChanges: List): Flow = flow { + suspend fun upload(localChanges: List): UploadSyncResult { val patches = patchGenerator.generate(localChanges) val requests = requestGenerator.generateUploadRequests(patches) val token = LocalChangeToken(localChanges.flatMap { it.token.ids }) - val total = requests.size - emit(UploadState.Started(total)) - requests.forEachIndexed { index, uploadRequest -> - try { - val response = dataSource.upload(uploadRequest) - emit( - getUploadResult(uploadRequest.resource.resourceType, response, token, total, index + 1), - ) - } catch (e: Exception) { - Timber.e(e) - emit(UploadState.Failure(ResourceSyncException(ResourceType.Bundle, e))) + + val successfulResponses = mutableListOf() + + for (uploadRequest in requests) { + when (val result = handleUploadRequest(uploadRequest)) { + is UploadRequestResult.Success -> successfulResponses.add(result.resource) + is UploadRequestResult.Failure -> return UploadSyncResult.Failure(result.exception, token) } } + + return UploadSyncResult.Success(token, successfulResponses) } - private fun getUploadResult( - requestResourceType: ResourceType, - response: Resource, - localChangeToken: LocalChangeToken, - total: Int, - completed: Int, - ) = - when { - response is Bundle && response.type == Bundle.BundleType.TRANSACTIONRESPONSE -> { - UploadState.Success(localChangeToken, response, total, completed) - } - response is OperationOutcome && response.issue.isNotEmpty() -> { - UploadState.Failure( - ResourceSyncException( - requestResourceType, - FHIRException(response.issueFirstRep.diagnostics), - ), - ) - } - else -> { - UploadState.Failure( - ResourceSyncException( - requestResourceType, - FHIRException("Unknown response for ${response.resourceType}"), - ), - ) + private suspend fun handleUploadRequest(uploadRequest: UploadRequest): UploadRequestResult { + return try { + val response = dataSource.upload(uploadRequest) + when { + response is Bundle && response.type == Bundle.BundleType.TRANSACTIONRESPONSE -> + UploadRequestResult.Success(response) + response is OperationOutcome && response.issue.isNotEmpty() -> + UploadRequestResult.Failure( + ResourceSyncException( + uploadRequest.resource.resourceType, + FHIRException(response.issueFirstRep.diagnostics), + ), + ) + else -> + UploadRequestResult.Failure( + ResourceSyncException( + uploadRequest.resource.resourceType, + FHIRException("Unknown response for ${uploadRequest.resource.resourceType}"), + ), + ) } + } catch (e: Exception) { + Timber.e(e) + UploadRequestResult.Failure(ResourceSyncException(ResourceType.Bundle, e)) } -} + } + + private sealed class UploadRequestResult { + data class Success(val resource: Resource) : UploadRequestResult() -internal sealed class UploadState { - data class Started(val total: Int) : UploadState() + data class Failure(val exception: ResourceSyncException) : UploadRequestResult() + } +} +sealed class UploadSyncResult { data class Success( val localChangeToken: LocalChangeToken, - val resource: Resource, - val total: Int, - val completed: Int, - ) : UploadState() + val resources: List, + ) : UploadSyncResult() - data class Failure(val syncError: ResourceSyncException) : UploadState() + data class Failure(val syncError: ResourceSyncException, val localChangeToken: LocalChangeToken) : + UploadSyncResult() } diff --git a/engine/src/main/java/com/google/android/fhir/testing/Utilities.kt b/engine/src/main/java/com/google/android/fhir/testing/Utilities.kt index b27519565e..d24acda000 100644 --- a/engine/src/main/java/com/google/android/fhir/testing/Utilities.kt +++ b/engine/src/main/java/com/google/android/fhir/testing/Utilities.kt @@ -32,6 +32,8 @@ import com.google.android.fhir.sync.download.BundleDownloadRequest import com.google.android.fhir.sync.download.DownloadRequest import com.google.android.fhir.sync.download.UrlDownloadRequest import com.google.android.fhir.sync.upload.LocalChangesFetchMode +import com.google.android.fhir.sync.upload.SyncUploadProgress +import com.google.android.fhir.sync.upload.UploadSyncResult import com.google.android.fhir.sync.upload.request.BundleUploadRequest import com.google.android.fhir.sync.upload.request.UploadRequest import com.google.common.truth.Truth.assertThat @@ -43,6 +45,7 @@ import java.util.LinkedList import kotlin.streams.toList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Meta import org.hl7.fhir.r4.model.Patient @@ -149,8 +152,14 @@ object TestFhirEngineImpl : FhirEngine { override suspend fun syncUpload( localChangesFetchMode: LocalChangesFetchMode, - upload: suspend (List) -> Flow>, - ) = upload(getLocalChanges(ResourceType.Patient, "123")).collect() + upload: suspend (List) -> UploadSyncResult, + ): Flow = flow { + emit(SyncUploadProgress(1, 1)) + when (val result = upload(getLocalChanges(ResourceType.Patient, "123"))) { + is UploadSyncResult.Success -> emit(SyncUploadProgress(0, 1)) + is UploadSyncResult.Failure -> emit(SyncUploadProgress(1, 1, result.syncError)) + } + } override suspend fun syncDownload( conflictResolver: ConflictResolver, diff --git a/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt b/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt index fbf400007e..2229cb0f9d 100644 --- a/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt +++ b/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt @@ -29,16 +29,19 @@ import com.google.android.fhir.search.LOCAL_LAST_UPDATED_PARAM import com.google.android.fhir.search.search import com.google.android.fhir.sync.AcceptLocalConflictResolver import com.google.android.fhir.sync.AcceptRemoteConflictResolver +import com.google.android.fhir.sync.ResourceSyncException import com.google.android.fhir.sync.upload.LocalChangesFetchMode +import com.google.android.fhir.sync.upload.SyncUploadProgress +import com.google.android.fhir.sync.upload.UploadSyncResult import com.google.android.fhir.testing.assertResourceEquals import com.google.android.fhir.testing.assertResourceNotEquals import com.google.android.fhir.testing.readFromFile import com.google.common.truth.Truth.assertThat import java.util.Date import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.hl7.fhir.exceptions.FHIRException import org.hl7.fhir.r4.model.Address import org.hl7.fhir.r4.model.CanonicalType @@ -312,22 +315,49 @@ class FhirEngineImplTest { } @Test - fun syncUpload_uploadLocalChange() = runBlocking { + fun syncUpload_uploadLocalChange_success() = runTest { val localChanges = mutableListOf() - fhirEngine.syncUpload(LocalChangesFetchMode.AllChanges) { - flow { + val emittedProgress = mutableListOf() + + fhirEngine + .syncUpload(LocalChangesFetchMode.AllChanges) { localChanges.addAll(it) - emit(LocalChangeToken(it.flatMap { it.token.ids }) to TEST_PATIENT_1) + UploadSyncResult.Success( + LocalChangeToken(it.flatMap { it.token.ids }), + listOf(), + ) } - } + .collect { emittedProgress.add(it) } assertThat(localChanges).hasSize(1) with(localChanges[0]) { - assertThat(this.resourceType).isEqualTo(ResourceType.Patient.toString()) - assertThat(this.resourceId).isEqualTo(TEST_PATIENT_1.id) - assertThat(this.type).isEqualTo(Type.INSERT) - assertThat(this.payload).isEqualTo(services.parser.encodeResourceToString(TEST_PATIENT_1)) + assertThat(resourceType).isEqualTo(ResourceType.Patient.toString()) + assertThat(resourceId).isEqualTo(TEST_PATIENT_1.id) + assertThat(type).isEqualTo(Type.INSERT) + assertThat(payload).isEqualTo(services.parser.encodeResourceToString(TEST_PATIENT_1)) } + + assertThat(emittedProgress).hasSize(2) + assertThat(emittedProgress.first()).isEqualTo(SyncUploadProgress(1, 1)) + assertThat(emittedProgress.last()).isEqualTo(SyncUploadProgress(0, 1)) + } + + @Test + fun syncUpload_uploadLocalChange_failure() = runBlocking { + val emittedProgress = mutableListOf() + val uploadError = ResourceSyncException(ResourceType.Patient, FHIRException("Did not work")) + fhirEngine + .syncUpload(LocalChangesFetchMode.AllChanges) { + UploadSyncResult.Failure( + uploadError, + LocalChangeToken(it.flatMap { it.token.ids }), + ) + } + .collect { emittedProgress.add(it) } + + assertThat(emittedProgress).hasSize(2) + assertThat(emittedProgress.first()).isEqualTo(SyncUploadProgress(1, 1)) + assertThat(emittedProgress.last()).isEqualTo(SyncUploadProgress(1, 1, uploadError)) } @Test diff --git a/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt b/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt index a280cec4a3..ebfb432d46 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt @@ -20,7 +20,7 @@ import androidx.test.core.app.ApplicationProvider import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.sync.download.DownloadState import com.google.android.fhir.sync.download.Downloader -import com.google.android.fhir.sync.upload.UploadState +import com.google.android.fhir.sync.upload.UploadSyncResult import com.google.android.fhir.sync.upload.Uploader import com.google.android.fhir.testing.TestFhirEngineImpl import com.google.common.truth.Truth.assertThat @@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher 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 @@ -40,6 +39,7 @@ import org.mockito.MockitoAnnotations import org.mockito.kotlin.any import org.robolectric.RobolectricTestRunner +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) class FhirSynchronizerTest { @@ -64,20 +64,15 @@ class FhirSynchronizerTest { ) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun `synchronize should return Success on successful download and upload`() = runTest(UnconfinedTestDispatcher()) { `when`(downloader.download()).thenReturn(flowOf(DownloadState.Success(listOf(), 10, 10))) `when`(uploader.upload(any())) .thenReturn( - flowOf( - UploadState.Success( - LocalChangeToken(listOf()), - Patient(), - 1, - 1, - ), + UploadSyncResult.Success( + LocalChangeToken(listOf()), + listOf(), ), ) @@ -90,6 +85,7 @@ class FhirSynchronizerTest { .containsExactly( SyncJobStatus.Started, SyncJobStatus.InProgress(SyncOperation.DOWNLOAD, total = 10, completed = 10), + SyncJobStatus.InProgress(SyncOperation.UPLOAD, total = 1, completed = 0), SyncJobStatus.InProgress(SyncOperation.UPLOAD, total = 1, completed = 1), SyncJobStatus.Finished, ) @@ -97,7 +93,6 @@ class FhirSynchronizerTest { assertThat(SyncJobStatus.Finished::class.java).isEqualTo(result::class.java) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun `synchronize should return Failed on failed download`() = runTest(UnconfinedTestDispatcher()) { @@ -105,13 +100,9 @@ class FhirSynchronizerTest { `when`(downloader.download()).thenReturn(flowOf(DownloadState.Failure(error))) `when`(uploader.upload(any())) .thenReturn( - flowOf( - UploadState.Success( - LocalChangeToken(listOf()), - Patient(), - 1, - 1, - ), + UploadSyncResult.Success( + LocalChangeToken(listOf()), + listOf(), ), ) @@ -123,23 +114,22 @@ class FhirSynchronizerTest { assertThat(emittedValues) .containsExactly( SyncJobStatus.Started, + SyncJobStatus.InProgress(SyncOperation.UPLOAD, total = 1, completed = 0), SyncJobStatus.InProgress(SyncOperation.UPLOAD, total = 1, completed = 1), SyncJobStatus.Failed(exceptions = listOf(error)), ) + assertThat(result).isInstanceOf(SyncJobStatus.Failed::class.java) assertThat(listOf(error)).isEqualTo((result as SyncJobStatus.Failed).exceptions) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun `synchronize should return Failed on failed upload`() = runTest(UnconfinedTestDispatcher()) { `when`(downloader.download()).thenReturn(flowOf(DownloadState.Success(listOf(), 10, 10))) val error = ResourceSyncException(ResourceType.Patient, Exception("Upload error")) `when`(uploader.upload(any())) - .thenReturn( - flowOf(UploadState.Failure(error)), - ) + .thenReturn(UploadSyncResult.Failure(error, LocalChangeToken(listOf()))) val emittedValues = mutableListOf() backgroundScope.launch { fhirSynchronizer.syncState.collect { emittedValues.add(it) } } @@ -150,6 +140,7 @@ class FhirSynchronizerTest { .containsExactly( SyncJobStatus.Started, SyncJobStatus.InProgress(SyncOperation.DOWNLOAD, total = 10, completed = 10), + SyncJobStatus.InProgress(SyncOperation.UPLOAD, total = 1, completed = 0), SyncJobStatus.Failed(exceptions = listOf(error)), ) assertThat(result).isInstanceOf(SyncJobStatus.Failed::class.java) diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/AllChangesLocalChangeFetcherTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/AllChangesLocalChangeFetcherTest.kt index b14637f446..89a334cf20 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/AllChangesLocalChangeFetcherTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/AllChangesLocalChangeFetcherTest.kt @@ -64,18 +64,18 @@ class AllChangesLocalChangeFetcherTest { @Test fun `getProgress when all local changes are removed`() = runTest { database.deleteUpdates(listOf(TEST_PATIENT_1, TEST_PATIENT_2)) - assertThat(fetcher.getProgress()).isEqualTo(FetchProgress(0, 2)) + assertThat(fetcher.getProgress()).isEqualTo(SyncUploadProgress(0, 2)) } @Test fun `getProgress when half the local changes are removed`() = runTest { database.deleteUpdates(listOf(TEST_PATIENT_1)) - assertThat(fetcher.getProgress()).isEqualTo(FetchProgress(1, 2)) + assertThat(fetcher.getProgress()).isEqualTo(SyncUploadProgress(1, 2)) } @Test fun `getProgress when none of the local changes are removed`() = runTest { - assertThat(fetcher.getProgress()).isEqualTo(FetchProgress(2, 2)) + assertThat(fetcher.getProgress()).isEqualTo(SyncUploadProgress(2, 2)) } companion object { diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderImplTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderTest.kt similarity index 77% rename from engine/src/test/java/com/google/android/fhir/sync/upload/UploaderImplTest.kt rename to engine/src/test/java/com/google/android/fhir/sync/upload/UploaderTest.kt index 7a82d10740..7036bfeed9 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderImplTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderTest.kt @@ -26,8 +26,8 @@ import com.google.common.truth.Truth.assertThat import java.net.ConnectException import java.time.Instant import java.util.UUID -import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.OperationOutcome @@ -38,31 +38,18 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) -class UploaderImplTest { +class UploaderTest { @Test - fun `upload should start`() = runBlocking { - val result = Uploader(BundleDataSource { Bundle() }).upload(localChanges).toList() - - assertThat(result.first()).isInstanceOf(UploadState.Started::class.java) - } - - @Test - fun `upload should succeed if response is transaction response`() = runBlocking { + fun `upload should succeed if response is transaction response`() = runTest { val result = Uploader( BundleDataSource { Bundle().apply { type = Bundle.BundleType.TRANSACTIONRESPONSE } }, ) .upload(localChanges) - .toList() - - assertThat(result).hasSize(2) - assertThat(result.first()).isInstanceOf(UploadState.Started::class.java) - assertThat(result.last()).isInstanceOf(UploadState.Success::class.java) - val success = result.last() as UploadState.Success - assertThat(success.total).isEqualTo(1) - assertThat(success.completed).isEqualTo(1) + assertThat(result).isInstanceOf(UploadSyncResult.Success::class.java) + with(result as UploadSyncResult.Success) { assertThat(resources).hasSize(1) } } @Test @@ -82,10 +69,8 @@ class UploaderImplTest { }, ) .upload(localChanges) - .toList() - assertThat(result).hasSize(2) - assertThat(result.last()).isInstanceOf(UploadState.Failure::class.java) + assertThat(result).isInstanceOf(UploadSyncResult.Failure::class.java) } @Test @@ -95,10 +80,8 @@ class UploaderImplTest { BundleDataSource { OperationOutcome() }, ) .upload(localChanges) - .toList() - assertThat(result).hasSize(2) - assertThat(result.last()).isInstanceOf(UploadState.Failure::class.java) + assertThat(result).isInstanceOf(UploadSyncResult.Failure::class.java) } @Test @@ -109,10 +92,8 @@ class UploaderImplTest { BundleDataSource { Bundle().apply { type = Bundle.BundleType.SEARCHSET } }, ) .upload(localChanges) - .toList() - assertThat(result).hasSize(2) - assertThat(result.last()).isInstanceOf(UploadState.Failure::class.java) + assertThat(result).isInstanceOf(UploadSyncResult.Failure::class.java) } @Test @@ -122,10 +103,8 @@ class UploaderImplTest { BundleDataSource { throw ConnectException("Failed to connect to server.") }, ) .upload(localChanges) - .toList() - assertThat(result).hasSize(2) - assertThat(result.last()).isInstanceOf(UploadState.Failure::class.java) + assertThat(result).isInstanceOf(UploadSyncResult.Failure::class.java) } companion object { From 546adf3b4c5dec157df2c260511b461f1630695c Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Fri, 6 Oct 2023 09:35:32 +0100 Subject: [PATCH 4/9] Remove CQL dependencies in the engine/benchmark module (#2229) --- engine/benchmark/build.gradle.kts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/engine/benchmark/build.gradle.kts b/engine/benchmark/build.gradle.kts index f920f01126..2343b4548d 100644 --- a/engine/benchmark/build.gradle.kts +++ b/engine/benchmark/build.gradle.kts @@ -56,19 +56,16 @@ configurations { } dependencies { + androidTestImplementation(Dependencies.Androidx.workRuntimeKtx) androidTestImplementation(Dependencies.AndroidxTest.benchmarkJunit) androidTestImplementation(Dependencies.AndroidxTest.extJunit) androidTestImplementation(Dependencies.AndroidxTest.runner) - androidTestImplementation(Dependencies.Cql.engineJackson) - androidTestImplementation(Dependencies.Cql.evaluator) - androidTestImplementation(Dependencies.Cql.evaluatorBuilder) - androidTestImplementation(Dependencies.junit) - androidTestImplementation(Dependencies.Kotlin.kotlinCoroutinesAndroid) - androidTestImplementation(Dependencies.truth) - androidTestImplementation(Dependencies.Androidx.workRuntimeKtx) androidTestImplementation(Dependencies.AndroidxTest.workTestingRuntimeKtx) - androidTestImplementation(Dependencies.mockWebServer) + androidTestImplementation(Dependencies.Kotlin.kotlinCoroutinesAndroid) androidTestImplementation(Dependencies.Retrofit.coreRetrofit) + androidTestImplementation(Dependencies.junit) + androidTestImplementation(Dependencies.mockWebServer) + androidTestImplementation(Dependencies.truth) androidTestImplementation(project(":engine")) // for test json files only From 5b112ffeef8afefafdf5f3813f4c3232d3578d95 Mon Sep 17 00:00:00 2001 From: Omar Ismail <44980219+omarismail94@users.noreply.github.com> Date: Fri, 6 Oct 2023 11:07:27 +0100 Subject: [PATCH 5/9] Update README.md (#2231) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 28264063d7..5f21cf67c0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Android FHIR SDK -[![master](https://github.com/google/android-fhir/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/google/android-fhir/actions/workflows/build.yml) [![master](https://storage.googleapis.com/android-fhir-build-badges/build.svg)](https://storage.googleapis.com/android-fhir-build-badges/build.html) [![codecov](https://codecov.io/gh/google/android-fhir/branch/master/graph/badge.svg?token=PDSC4WRDTQ)](https://codecov.io/gh/google/android-fhir/branch/master) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![project chat](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg)](https://chat.fhir.org/#narrow/stream/276344-android) +[![master](https://github.com/google/android-fhir/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/google/android-fhir/actions/workflows/build.yml) [![codecov](https://codecov.io/gh/google/android-fhir/branch/master/graph/badge.svg?token=PDSC4WRDTQ)](https://codecov.io/gh/google/android-fhir/branch/master) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![project chat](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg)](https://chat.fhir.org/#narrow/stream/276344-android) The Android FHIR SDK is a set of Kotlin libraries for building offline-capable, mobile-first healthcare applications using the [HL7® FHIR® standard](https://www.hl7.org/fhir/) on Android. It @@ -15,7 +15,7 @@ The SDK contains the following libraries: | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------| | Data Capture Library | [![Google Maven](https://badgen.net/maven/v/metadata-url/dl.google.com/dl/android/maven2/com/google/android/fhir/data-capture/maven-metadata.xml)](https://maven.google.com/web/index.html?#com.google.android.fhir:data-capture) | [code](https://github.com/google/android-fhir/tree/master/datacapture)| [wiki](https://github.com/google/android-fhir/wiki/Structured-Data-Capture-Library) | Android 7.0 (API level 24) | Collect, validate, and process healthcare data on Android | | FHIR Engine Library | [![Google Maven](https://badgen.net/maven/v/metadata-url/dl.google.com/dl/android/maven2/com/google/android/fhir/engine/maven-metadata.xml)](https://maven.google.com/web/index.html?#com.google.android.fhir:engine) | [code](https://github.com/google/android-fhir/tree/master/engine) | [wiki](https://github.com/google/android-fhir/wiki/FHIR-Engine-Library) | Android 7.0 (API level 24) | Store and manage FHIR resources locally on Android and synchronize with FHIR server | -| Workflow Library | [![Google Maven](https://badgen.net/maven/v/metadata-url/dl.google.com/dl/android/maven2/com/google/android/fhir/workflow/maven-metadata.xml)](https://maven.google.com/web/index.html?#com.google.android.fhir:workflow) | [code](https://github.com/google/android-fhir/tree/master/workflow) | [wiki](https://github.com/google/android-fhir/wiki/Workflow-Library) | Android 8.0 (API level 26) | Provide decision support and analytics in clinical workflow on Android including implementation of specific FHIR operations ($measure_evaluate and $apply) | +| Workflow Library | [![Google Maven](https://badgen.net/maven/v/metadata-url/dl.google.com/dl/android/maven2/com/google/android/fhir/workflow/maven-metadata.xml)](https://maven.google.com/web/index.html?#com.google.android.fhir:workflow) | [code](https://github.com/google/android-fhir/tree/master/workflow) | [wiki](https://github.com/google/android-fhir/wiki/Workflow-Library) | Android 7.0 (API level 24) | Provide decision support and analytics in clinical workflow on Android including implementation of specific FHIR operations ($measure_evaluate and $apply) | ## Demo apps From 1999e48d1c5b98f1462d3889f9b4a61a0e53e5c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vorburger=20=E2=9B=91=EF=B8=8F?= Date: Fri, 6 Oct 2023 12:49:12 +0200 Subject: [PATCH 6/9] Ignore flaky UI Test, for now (see #1482, fixes #2197) (#2203) --- ...nnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt index 94703b1771..7c05378caa 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt @@ -29,6 +29,7 @@ import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.filters.SdkSuppress import androidx.test.platform.app.InstrumentationRegistry import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.DisplayItemControlType @@ -261,6 +262,7 @@ class QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest { } @Test + @SdkSuppress(minSdkVersion = 33) // TODO https://github.com/google/android-fhir/issues/1482 FIXME fun selectOther_shouldScrollDownToShowAddAnotherAnswer() { val questionnaireItem = answerOptions( @@ -327,6 +329,7 @@ class QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest { } @Test + @SdkSuppress(minSdkVersion = 33) // TODO https://github.com/google/android-fhir/issues/1482 FIXME fun clickAddAnotherAnswer_shouldScrollDownToShowAddAnotherAnswer() { val questionnaireItem = answerOptions( From a9b685a56ba184b041c3825bf7c3f406dda60b76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vorburger=20=E2=9B=91=EF=B8=8F?= Date: Fri, 6 Oct 2023 13:33:23 +0200 Subject: [PATCH 7/9] Add CodeQL to GitHub CI Action (fixes #2185) (#2207) --- .github/workflows/codeql.yaml | 51 +++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/codeql.yaml diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml new file mode 100644 index 0000000000..6b30b500f4 --- /dev/null +++ b/.github/workflows/codeql.yaml @@ -0,0 +1,51 @@ +name: "CodeQL" + +on: + push: + branches: [ "master" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "master" ] + schedule: + - cron: '32 13 * * 2' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-22.04-64core + timeout-minutes: 60 + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'java' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Switch to Java 17 from Eclipse Temurin distro + uses: actions/setup-java@v3 + with: + java-version: 17 + distribution: temurin + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + + # TODO: use Autobuild instead of ./gradlew after https://github.com/github/codeql-action/issues/1417 is fixed + # - name: Autobuild + # uses: github/codeql-action/autobuild@v2 + - name: Build with Gradle + run: ./gradlew --scan --full-stacktrace -Dorg.gradle.dependency.verification=off compileDebugAndroidTestSources + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" From a71109be11777e5369987bdbc60b77f06b4fdc62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vorburger=20=E2=9B=91=EF=B8=8F?= Date: Fri, 6 Oct 2023 15:59:01 +0200 Subject: [PATCH 8/9] Create dependabot.yaml (fixes #2195) (#2196) Co-authored-by: Omar Ismail <44980219+omarismail94@users.noreply.github.com> --- .github/dependabot.yaml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/dependabot.yaml diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000000..04193c4016 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,20 @@ +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + # Gotta Catch 'Em All! (i.e. don't wait and propose them only "trickled") + open-pull-requests-limit: 99 + + - package-ecosystem: gradle + directory: / + schedule: + interval: weekly + day: monday + time: "04:00" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "04:00" From 0818bd03415b955a4c33bec2a7df1c45323d55b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vorburger=20=E2=9B=91=EF=B8=8F?= Date: Sun, 8 Oct 2023 14:13:16 +0200 Subject: [PATCH 9/9] Add initial docs/index.html (re. #2232) (#2233) --- docs/index.html | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/index.html diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000000..287d8d8294 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,15 @@ + + + + +

Android FHIR SDK Technical Documentation

+ +

API Docs

+ + + +