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 0000000000..956c296c5d Binary files /dev/null and b/knowledge/testdata/okhttp_downloader/package.tgz differ 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",