Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for custom headers in the download http requests and use ETags for upload #2009

Merged
merged 8 commits into from
Jun 11, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import com.google.android.fhir.demo.data.FhirSyncWorker
import com.google.android.fhir.search.search
import com.google.android.fhir.sync.Sync
import com.google.android.fhir.sync.remote.HttpLogger
import org.hl7.fhir.r4.model.Patient
import timber.log.Timber

class FhirApplication : Application(), DataCaptureConfig.Provider {
Expand All @@ -45,6 +46,7 @@ class FhirApplication : Application(), DataCaptureConfig.Provider {
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
Patient.IDENTIFIER
FhirEngineProvider.init(
FhirEngineConfiguration(
enableEncryptionIfSupported = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ internal class FhirEngineImpl(private val database: Database, private val contex
database.updateVersionIdAndLastUpdated(
id,
type,
response.etag,
getVersionFromETag(response.etag),
response.lastModified.toInstant()
)
}
Expand All @@ -179,6 +179,20 @@ internal class FhirEngineImpl(private val database: Database, private val contex
}
}

/**
* FHIR uses weak ETag that look something like W/"MTY4NDMyODE2OTg3NDUyNTAwMA", so we need to
* extract version from it. See https://hl7.org/fhir/http.html#Http-Headers.
*/
aditya-07 marked this conversation as resolved.
Show resolved Hide resolved
private fun getVersionFromETag(eTag: String) =
// The server should always return a weak etag that starts with W, but if it server returns a
// strong tag, we store it as-is. The http-headers for conditional upload like if-match will
// always add value as a weak tag.
if (eTag.startsWith("W/")) {
aditya-07 marked this conversation as resolved.
Show resolved Hide resolved
eTag.split("\"")[1]
} else {
eTag
}

/**
* May return a Pair of versionId and resource type extracted from the
* [Bundle.BundleEntryResponseComponent.location].
Expand Down
11 changes: 9 additions & 2 deletions engine/src/main/java/com/google/android/fhir/sync/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,19 @@ data class BackoffCriteria(

/**
* Configuration for max number of resources to be uploaded in a Bundle.The default size is
* [DEFAULT_BUNDLE_SIZE].
* [DEFAULT_BUNDLE_SIZE]. The application developer may also configure if the eTag should be used
* for edit and delete requests during the upload. Default is to use the eTag.
*/
data class UploadConfiguration(
/**
* Number of [Resource]s to be added in a singe [Bundle] for upload and default is
* [DEFAULT_BUNDLE_SIZE]
*/
val uploadBundleSize: Int = DEFAULT_BUNDLE_SIZE
val uploadBundleSize: Int = DEFAULT_BUNDLE_SIZE,

/**
* Use if-match http header with e-tag for upload requests. See ETag
* [section](https://hl7.org/fhir/http.html#Http-Headers) for more details.
*/
val useETagForUpload: Boolean = true,
)
16 changes: 3 additions & 13 deletions engine/src/main/java/com/google/android/fhir/sync/DataSource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,13 @@ import org.hl7.fhir.r4.model.Resource
* operations are [Bundle] based to optimize network traffic.
*/
internal interface DataSource {
/**
* @return [Bundle] of type [BundleType.SEARCHSET] for a successful operation, [OperationOutcome]
* otherwise. Call this api with the relative path of the resource search url to be downloaded.
*/
suspend fun download(path: String): Resource

/**
* @return [Bundle] on a successful operation, [OperationOutcome] otherwise. Call this api with
* the [Bundle] that contains individual requests bundled together to be downloaded from the
* server. (e.g. https://www.hl7.org/fhir/bundle-request-medsallergies.json.html)
*/
suspend fun download(bundle: Bundle): Resource
/** @return [Bundle] on a successful operation, [OperationOutcome] otherwise. */
suspend fun download(request: Request): Resource

/**
* @return [Bundle] of type [BundleType.TRANSACTIONRESPONSE] for a successful operation,
* [OperationOutcome] otherwise. Call this api with the [Bundle] that needs to be uploaded to the
* server.
*/
suspend fun upload(bundle: Bundle): Resource
suspend fun upload(request: BundleRequest): Resource
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,25 +45,66 @@ interface DownloadWorkManager {
suspend fun processResponse(response: Resource): Collection<Resource>
}

sealed class Request {
/**
* Structure represents a request that can be made to download resources from the FHIR server. The
* request may contain http headers for conditional requests for getting precise results.
*
* Implementations of [Request] are [UrlRequest] and [BundleRequest] and the application developers
* may choose the appropriate [Request.of] companion functions to create request objects.
*
* **UrlRequest**
*
* The application developer may use a request like below to get an update on Patient/123 since it
* was last downloaded.
* ```
* Request.of("/Patient/123", mapOf("If-Modified-Since" to "knownLastUpdatedOfPatient123"))
* ```
* **BundleRequest**
*
* The application developer may use a request like below to download multiple resources in a single
* shot.
*
* ```
* Request.of(Bundle().apply {
* addEntry(Bundle.BundleEntryComponent().apply {
* request = Bundle.BundleEntryRequestComponent().apply {
* url = "Patient/123"
* method = Bundle.HTTPVerb.GET
* }
* })
* addEntry(Bundle.BundleEntryComponent().apply {
* request = Bundle.BundleEntryRequestComponent().apply {
* url = "Patient/124"
* method = Bundle.HTTPVerb.GET
* }
* })
* })
* ```
*/
sealed class Request(open val headers: Map<String, String>) {
aditya-07 marked this conversation as resolved.
Show resolved Hide resolved
companion object {
/** @return [UrlRequest] for a FHIR search [url]. */
fun of(url: String) = UrlRequest(url)
fun of(url: String, headers: Map<String, String> = emptyMap()) = UrlRequest(url, headers)

/** @return [BundleRequest] for a FHIR search [bundle]. */
fun of(bundle: Bundle) = BundleRequest(bundle)
fun of(bundle: Bundle, headers: Map<String, String> = emptyMap()) =
BundleRequest(bundle, headers)
}
}

/**
* A [url] based FHIR request to download resources from the server. e.g.
* `Patient?given=valueGiven&family=valueFamily`
*/
data class UrlRequest(val url: String) : Request()
data class UrlRequest
internal constructor(val url: String, override val headers: Map<String, String> = emptyMap()) :
Request(headers)

/**
* A [bundle] based FHIR request to download resources from the server. For an example, see
* [bundle-request-medsallergies.json](https://www.hl7.org/fhir/bundle-request-medsallergies.json.html)
* .
*/
data class BundleRequest(val bundle: Bundle) : Request()
data class BundleRequest
internal constructor(val bundle: Bundle, override val headers: Map<String, String> = emptyMap()) :
Request(headers)
Original file line number Diff line number Diff line change
Expand Up @@ -89,18 +89,20 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter

Timber.v("Subscribed to flow for progress")
val result =
FhirSynchronizer(
applicationContext,
getFhirEngine(),
BundleUploader(
dataSource,
TransactionBundleGenerator.getDefault(),
LocalChangesPaginator.create(getUploadConfiguration())
),
DownloaderImpl(dataSource, getDownloadWorkManager()),
getConflictResolver()
)
.apply { subscribe(flow) }
with(getUploadConfiguration()) {
FhirSynchronizer(
applicationContext,
getFhirEngine(),
BundleUploader(
dataSource,
TransactionBundleGenerator.getDefault(useETagForUpload),
LocalChangesPaginator.create(this)
),
DownloaderImpl(dataSource, getDownloadWorkManager()),
getConflictResolver()
)
.apply { subscribe(flow) }
}
.synchronize()
val output = buildWorkData(result)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ internal class DownloaderImpl(
while (request != null) {
try {
resourceTypeToDownload = request.toResourceType()
downloadWorkManager.processResponse(download(request)).toList().let {
downloadWorkManager.processResponse(dataSource.download(request)).toList().let {
downloadedResourcesCount += it.size
emit(DownloadState.Success(it, totalResourcesToDownloadCount, downloadedResourcesCount))
}
Expand All @@ -64,12 +64,6 @@ internal class DownloaderImpl(
}
}

private suspend fun download(request: Request) =
when (request) {
is UrlRequest -> dataSource.download(request.url)
is BundleRequest -> dataSource.download(request.bundle)
}

private fun Request.toResourceType() =
when (this) {
is UrlRequest ->
Expand All @@ -82,7 +76,7 @@ internal class DownloaderImpl(
.getSummaryRequestUrls()
.map { summary ->
summary.key to
runCatching { dataSource.download(summary.value) }
runCatching { dataSource.download(Request.of(summary.value)) }
.onFailure { Timber.e(it) }
.getOrNull()
.takeIf { it is Bundle }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,23 @@

package com.google.android.fhir.sync.remote

import com.google.android.fhir.sync.BundleRequest
import com.google.android.fhir.sync.DataSource
import org.hl7.fhir.r4.model.Bundle
import com.google.android.fhir.sync.Request
import com.google.android.fhir.sync.UrlRequest

/**
* Implementation of [DataSource] to sync data with the FHIR server using HTTP method calls.
* @param fhirHttpService Http service to make requests to the server.
*/
internal class FhirHttpDataSource(private val fhirHttpService: FhirHttpService) : DataSource {

override suspend fun download(path: String) = fhirHttpService.get(path)
override suspend fun download(request: Request) =
when (request) {
is UrlRequest -> fhirHttpService.get(request.url, request.headers)
is BundleRequest -> fhirHttpService.post(request.bundle, request.headers)
}

override suspend fun download(bundle: Bundle) = fhirHttpService.post(bundle)

override suspend fun upload(bundle: Bundle) = fhirHttpService.post(bundle)
override suspend fun upload(request: BundleRequest) =
fhirHttpService.post(request.bundle, request.headers)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ internal interface FhirHttpService {
* @return The server may return a particular [Resource], [Bundle] or [OperationOutcome] based on
* the request processing.
*/
suspend fun get(path: String): Resource
suspend fun get(path: String, headers: Map<String, String>): Resource

/**
* Makes a HTTP-POST method request to the server with the [Bundle] as request-body.
* @return The server may return [Bundle] or [OperationOutcome] based on the request processing.
*/
suspend fun post(bundle: Bundle): Resource
suspend fun post(bundle: Bundle, headers: Map<String, String>): Resource
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,18 @@ import org.hl7.fhir.r4.model.Resource
import retrofit2.Retrofit
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.HeaderMap
import retrofit2.http.POST
import retrofit2.http.Url

/** Retrofit service to make http requests to the FHIR server. */
internal interface RetrofitHttpService : FhirHttpService {

@GET override suspend fun get(@Url path: String): Resource
@GET
override suspend fun get(@Url path: String, @HeaderMap headers: Map<String, String>): Resource

@POST(".") override suspend fun post(@Body bundle: Bundle): Resource
@POST(".")
override suspend fun post(@Body bundle: Bundle, @HeaderMap headers: Map<String, String>): Resource

class Builder(
private val baseUrl: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package com.google.android.fhir.sync.upload
import com.google.android.fhir.LocalChange
import com.google.android.fhir.db.impl.dao.LocalChangeToken
import com.google.android.fhir.sync.DataSource
import com.google.android.fhir.sync.Request
import com.google.android.fhir.sync.ResourceSyncException
import com.google.android.fhir.sync.UploadResult
import com.google.android.fhir.sync.Uploader
Expand Down Expand Up @@ -47,7 +48,7 @@ internal class BundleUploader(
bundleGenerator.generate(localChangesPaginator.page(localChanges)).forEach {
(bundle, localChangeTokens) ->
try {
val response = dataSource.upload(bundle)
val response = dataSource.upload(Request.of(bundle))

completed += bundle.entry.size
emit(getUploadResult(response, localChangeTokens, total, completed))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ import org.hl7.fhir.r4.model.UriType
* more info regarding the supported [Bundle.HTTPVerb].
*/
internal abstract class HttpVerbBasedBundleEntryComponentGenerator(
private val httpVerb: Bundle.HTTPVerb
private val httpVerb: Bundle.HTTPVerb,
private val useETagForUpload: Boolean,
) {

/**
Expand All @@ -56,7 +57,17 @@ internal abstract class HttpVerbBasedBundleEntryComponentGenerator(

private fun getEntryRequest(localChange: LocalChange) =
Bundle.BundleEntryRequestComponent(
Enumeration(Bundle.HTTPVerbEnumFactory()).apply { value = httpVerb },
UriType("${localChange.resourceType}/${localChange.resourceId}")
)
Enumeration(Bundle.HTTPVerbEnumFactory()).apply { value = httpVerb },
UriType("${localChange.resourceType}/${localChange.resourceId}")
)
.apply {
if (useETagForUpload) {
// FHIR supports weak Etag, See ETag section https://hl7.org/fhir/http.html#Http-Headers
when (localChange.type) {
LocalChange.Type.UPDATE,
LocalChange.Type.DELETE -> ifMatch = "W/\"${localChange.versionId}\""
LocalChange.Type.INSERT -> {}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,17 @@ import org.hl7.fhir.instance.model.api.IBaseResource
import org.hl7.fhir.r4.model.Binary
import org.hl7.fhir.r4.model.Bundle

internal object HttpPutForCreateEntryComponentGenerator :
HttpVerbBasedBundleEntryComponentGenerator(Bundle.HTTPVerb.PUT) {
internal class HttpPutForCreateEntryComponentGenerator(useETagForUpload: Boolean) :
HttpVerbBasedBundleEntryComponentGenerator(Bundle.HTTPVerb.PUT, useETagForUpload) {
override fun getEntryResource(localChange: LocalChange): IBaseResource {
return FhirContext.forCached(FhirVersionEnum.R4)
.newJsonParser()
.parseResource(localChange.payload)
}
}

internal object HttpPatchForUpdateEntryComponentGenerator :
HttpVerbBasedBundleEntryComponentGenerator(Bundle.HTTPVerb.PATCH) {
internal class HttpPatchForUpdateEntryComponentGenerator(useETagForUpload: Boolean) :
HttpVerbBasedBundleEntryComponentGenerator(Bundle.HTTPVerb.PATCH, useETagForUpload) {
override fun getEntryResource(localChange: LocalChange): IBaseResource {
return Binary().apply {
contentType = ContentTypes.APPLICATION_JSON_PATCH
Expand All @@ -43,7 +43,7 @@ internal object HttpPatchForUpdateEntryComponentGenerator :
}
}

internal object HttpDeleteEntryComponentGenerator :
HttpVerbBasedBundleEntryComponentGenerator(Bundle.HTTPVerb.DELETE) {
internal class HttpDeleteEntryComponentGenerator(useETagForUpload: Boolean) :
HttpVerbBasedBundleEntryComponentGenerator(Bundle.HTTPVerb.DELETE, useETagForUpload) {
override fun getEntryResource(localChange: LocalChange): IBaseResource? = null
}
Loading
Loading