Skip to content

Commit

Permalink
Added SHL Decoding Interfaces and Implementations (#2434)
Browse files Browse the repository at this point in the history
* Added interfaces and utils needed for decoding

* Separated decoderImpl into smaller functions and added more tests for readShlUtils

* Started implementing tests for decoderImpl

* Finished unit tests for ReadSHLinkUtils

* Wrote test for SHLinkDecoder

* Added comments and cleaned code

* More DecoderImpl unit tests added

* Ran gradle checks

* Removed constructShl function from impl and SHLScanDataInput from Decoder parameters - refactored tests to acommodate this

* Changed test names to use backticks to make them more readable

* Migrating asserts to the Truth library

* Migrated all decoding tests to use Truth library

* Added helpful comments to DecoderImpl

* Added kdoc to the Decoder interface

* Changed Base64 library from java to android - so minSdk can be kept at 24

* Changed Retrofit object variable name to be more descriptive

* Extracted common variables in decoderImpl tests

* Changed shLinkScanData create function to be inside the companion object and removed public constructors

* Improved kdoc in interface

* Refactored decodeSHLink to take in passcode and recipient as separate strings - instead of a predefined JSON object

* Ran gradle checks

* Correct the IPSDocument create function

* Added named parameter in IPSDocument create function

* Changed tests to use assertThrows and improved decoder tests

---------

Co-authored-by: aditya-07 <[email protected]>
  • Loading branch information
hugomilosz and aditya-07 committed Jun 18, 2024
1 parent 70fd2ae commit d3841eb
Show file tree
Hide file tree
Showing 11 changed files with 919 additions and 33 deletions.
1 change: 1 addition & 0 deletions document/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ dependencies {
testImplementation(Dependencies.mockitoInline)
testImplementation(Dependencies.Kotlin.kotlinCoroutinesTest)
testImplementation(Dependencies.mockWebServer)
testImplementation(Dependencies.truth)

androidTestImplementation(Dependencies.AndroidxTest.extJunit)
androidTestImplementation(Dependencies.Espresso.espressoCore)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023 Google LLC
* Copyright 2023-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -17,8 +17,9 @@
package com.google.android.fhir.document

import org.hl7.fhir.r4.model.Bundle
import org.hl7.fhir.r4.model.Patient
import org.hl7.fhir.r4.model.Composition
import org.hl7.fhir.r4.model.Resource
import org.hl7.fhir.r4.model.ResourceType

/**
* Represents an International Patient Summary (IPS) document, associating it with a specific
Expand All @@ -31,13 +32,29 @@ import org.hl7.fhir.r4.model.Resource
*
* @property document The FHIR Bundle itself, which contains the IPS document
* @property titles A list of titles of the sections present in the document.
* @property patient The FHIR Patient resource associated with the IPS document.
*/
data class IPSDocument(
val document: Bundle,
val titles: ArrayList<Title>,
val patient: Patient,
)
) {
companion object {
fun create(bundle: Bundle): IPSDocument {
if (bundle.entry.isNotEmpty()) {
val composition =
bundle.entry
?.firstOrNull { it.resource.resourceType == ResourceType.Composition }
?.resource as Composition
val titles =
composition.section.map {
val titleText = it.title ?: "Unknown Section"
Title(titleText, ArrayList())
} as ArrayList<Title>
return IPSDocument(bundle, titles)
}
return IPSDocument(bundle, titles = arrayListOf())
}
}
}

/**
* Represents a title, which corresponds to a section present in the IPS document.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023 Google LLC
* Copyright 2023-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -18,16 +18,17 @@ package com.google.android.fhir.document

import com.google.android.fhir.NetworkConfiguration
import com.google.android.fhir.sync.remote.GzipUploadInterceptor
import com.google.android.fhir.sync.remote.HttpLogger
import java.util.concurrent.TimeUnit
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.ResponseBody
import okhttp3.logging.HttpLoggingInterceptor
import org.json.JSONObject
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Headers
import retrofit2.http.POST
Expand All @@ -53,16 +54,26 @@ interface RetrofitSHLService {
@Header("Authorization") authorization: String,
): Response<ResponseBody>

/* POST request to the SHL's manifest url to get the list of files associated with the link */
@POST
@Headers("Content-Type: application/json")
suspend fun getFilesFromManifest(
@Url path: String,
@Body jsonData: JSONObject,
): Response<ResponseBody>

/* GET request if files are stored in an external "location" */
@GET
suspend fun getFromLocation(
@Url path: String,
): Response<ResponseBody>

class Builder(
private val baseUrl: String,
private val networkConfiguration: NetworkConfiguration,
) {
private var httpLoggingInterceptor: HttpLoggingInterceptor? = null

fun setHttpLogger(httpLogger: HttpLogger) = apply {
httpLoggingInterceptor = httpLogger.toOkHttpLoggingInterceptor()
}

fun build(): RetrofitSHLService {
val client =
OkHttpClient.Builder()
Expand All @@ -83,25 +94,5 @@ interface RetrofitSHLService {
.build()
.create(RetrofitSHLService::class.java)
}

/* Maybe move these to different class */
private fun HttpLogger.toOkHttpLoggingInterceptor() =
HttpLoggingInterceptor(log).apply {
level = configuration.level.toOkhttpLogLevel()
configuration.headersToIgnore?.forEach { this.redactHeader(it) }
}

private fun HttpLogger.Level.toOkhttpLogLevel() =
when (this) {
HttpLogger.Level.NONE -> HttpLoggingInterceptor.Level.NONE
HttpLogger.Level.BASIC -> HttpLoggingInterceptor.Level.BASIC
HttpLogger.Level.HEADERS -> HttpLoggingInterceptor.Level.HEADERS
HttpLogger.Level.BODY -> HttpLoggingInterceptor.Level.BODY
}
}

companion object {
fun builder(baseUrl: String, networkConfiguration: NetworkConfiguration) =
Builder(baseUrl, networkConfiguration)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* Copyright 2024 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:https://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.document.decode

import android.util.Base64
import com.nimbusds.jose.JWEDecrypter
import com.nimbusds.jose.JWEObject
import com.nimbusds.jose.crypto.DirectDecrypter
import java.io.ByteArrayOutputStream
import java.util.zip.DataFormatException
import java.util.zip.Inflater
import org.json.JSONObject

object ReadSHLinkUtils {

/* Extracts the part of the link after the 'shlink:/' */
fun extractUrl(scannedData: String): String {
if (scannedData.contains("shlink:/")) {
return scannedData.substringAfterLast("shlink:/")
}
throw IllegalArgumentException("Not a valid SHLink")
}

/* Decodes the extracted url from Base64Url to a byte array */
fun decodeUrl(extractedUrl: String): ByteArray {
if (extractedUrl.isEmpty()) {
throw IllegalArgumentException("Not a valid Base64 encoded string")
}
try {
return Base64.decode(extractedUrl.toByteArray(), Base64.URL_SAFE)
} catch (err: IllegalArgumentException) {
throw IllegalArgumentException("Not a valid Base64 encoded string")
}
}

/* Returns a string of data found in the verifiableCredential field in the given JSON */
fun extractVerifiableCredential(jsonString: String): String {
val jsonObject = JSONObject(jsonString)
if (jsonObject.has("verifiableCredential")) {
val verifiableCredentialArray = jsonObject.getJSONArray("verifiableCredential")

if (verifiableCredentialArray.length() > 0) {
// Assuming you want the first item from the array
return verifiableCredentialArray.getString(0)
}
}
return ""
}

/* Decodes and decompresses the payload in a JWT token */
fun decodeAndDecompressPayload(token: String): String {
try {
val tokenParts = token.split('.')
if (tokenParts.size < 2) {
throw Error("Invalid JWT token passed in")
}
val decoded = Base64.decode(tokenParts[1], Base64.URL_SAFE)
val inflater = Inflater(true)
inflater.setInput(decoded)
val initialBufferSize = 100000
val decompressedBytes = ByteArrayOutputStream(initialBufferSize)
val buffer = ByteArray(8192)

try {
while (!inflater.finished()) {
val length = inflater.inflate(buffer)
decompressedBytes.write(buffer, 0, length)
}
decompressedBytes.close()
} catch (e: DataFormatException) {
throw Error("$e.printStackTrace()")
}
inflater.end()
return decompressedBytes.toByteArray().decodeToString()
} catch (err: Error) {
throw Error("Invalid JWT token passed in: $err")
}
}

/* Decodes and decompresses the embedded health data from a JWE token into a string */
fun decodeShc(responseBody: String, key: String): String {
try {
if (responseBody.isEmpty() or key.isEmpty()) {
throw IllegalArgumentException("The provided strings should not be empty")
}
val jweObject = JWEObject.parse(responseBody)
val decodedKey: ByteArray = Base64.decode(key, Base64.URL_SAFE)
val decrypter: JWEDecrypter = DirectDecrypter(decodedKey)
jweObject.decrypt(decrypter)
return jweObject.payload.toString()
} catch (err: Exception) {
throw Exception("JWE decryption failed: $err")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* Copyright 2024 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:https://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.document.decode

import com.google.android.fhir.document.IPSDocument

/**
* The [SHLinkDecoder] interface defines a contract for decoding Smart Health Links (SHLs) into
* [IPSDocument] objects. Implementations of this interface are responsible for decoding and
* decompressing SHLs, fetching associated health data from external sources, and creating
* IPSDocument instances.
*
* The process of decoding SHLs is outlined in its documentation
* [SHL Documentation](https://docs.smarthealthit.org/smart-health-links/).
*
* ## Example Decoding Process:
* A SHL is formatted as `[optionalViewer]shlink:/[Base64-Encoded Payload]` (e.g.,
* `shlink:/eyJsYWJ...`). First, extract the portion of the link after 'shlink:/' and decode this to
* give a SHL Payload. SHL Payloads are structured as:
* ```
* {
* "url": manifest url,
* "key": SHL-specific key,
* "label": "2023-07-12",
* "flag": "LPU",
* "exp": expiration time,
* "v": SHL Protocol Version
* }
* ```
*
* The label, flag, exp, and v properties are optional.
*
* Send a POST request to the manifest URL with a header of "Content-Type":"application/json" and a
* body with a "Recipient", a "Passcode" if the "P" flag is present and optionally
* "embeddedLengthMax":INT. Example request body:
*
* ```
* {
* "recipient" : "example_name",
* "passcode" : "example_passcode"
* }
* ```
* ```
*
* If the POST request is successful, a list of files is returned.
* Example response:
*
* ```
*
* { "files" :
* [ { "contentType": "application/smart-health-card", "location":"https://bucket.cloud.example..." }, { "contentType": "application/smart-health-card", "embedded":"eyJhb..." } ]
* }
*
* ```
*
* A file can be one of two types:
* - Location: If the resource is stored in a location, a single GET request can be made to retrieve the data.
* - Embedded: If the file type is embedded, the data is a JWE token which can be decoded with the SHL-specific key.
*/
interface SHLinkDecoder {

/**
* Decodes and decompresses a Smart Health Link (SHL) into an [IPSDocument] object.
*
* @param shLink The full Smart Health Link.
* @param recipient The recipient for the manifest request.
* @param passcode The passcode for the manifest request (optional, will be null if the P flag is
* not present in the SHL payload).
* @return An [IPSDocument] object if decoding is successful, otherwise null.
*/
suspend fun decodeSHLinkToDocument(
shLink: String,
recipient: String,
passcode: String?,
): IPSDocument?
}
Loading

0 comments on commit d3841eb

Please sign in to comment.