Skip to content

Commit

Permalink
Support for headers in the http requests. (#2009)
Browse files Browse the repository at this point in the history
* Added header and etag support

* Review changes

* Added separate tests for if-Match header in bundle
  • Loading branch information
aditya-07 committed Jun 11, 2023
1 parent e322dd4 commit 5e8698f
Show file tree
Hide file tree
Showing 18 changed files with 273 additions and 108 deletions.
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.
*/
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/")) {
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>) {
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)
26 changes: 14 additions & 12 deletions engine/src/main/java/com/google/android/fhir/sync/FhirSyncWorker.kt
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 && !localChange.versionId.isNullOrEmpty()) {
// 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

0 comments on commit 5e8698f

Please sign in to comment.