mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
Merge pull request #3620 from vitorpamplona/claude/medicaldata-jackson-kotlin-serialization-c15i39
Replace Jackson with kotlinx.serialization for FHIR parsing
This commit is contained in:
@@ -21,51 +21,71 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.serialization.DeserializationStrategy
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
@JsonTypeInfo(
|
||||
use = JsonTypeInfo.Id.NAME,
|
||||
include = JsonTypeInfo.As.PROPERTY,
|
||||
property = "resourceType",
|
||||
)
|
||||
@JsonSubTypes(
|
||||
JsonSubTypes.Type(value = Practitioner::class, name = "Practitioner"),
|
||||
JsonSubTypes.Type(value = Patient::class, name = "Patient"),
|
||||
JsonSubTypes.Type(value = Bundle::class, name = "Bundle"),
|
||||
JsonSubTypes.Type(value = VisionPrescription::class, name = "VisionPrescription"),
|
||||
)
|
||||
/**
|
||||
* FHIR resources are polymorphic on the `resourceType` string. We only model the
|
||||
* handful of types Amethyst renders; anything else (and any resource with a
|
||||
* missing/unrecognized type) decodes into [UnknownResource] so a mixed [Bundle]
|
||||
* never fails to parse just because it carries a type we don't know about.
|
||||
*/
|
||||
object ResourceSerializer : JsonContentPolymorphicSerializer<Resource>(Resource::class) {
|
||||
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<Resource> =
|
||||
when (element.jsonObject["resourceType"]?.jsonPrimitive?.content) {
|
||||
"Practitioner" -> Practitioner.serializer()
|
||||
"Patient" -> Patient.serializer()
|
||||
"Bundle" -> Bundle.serializer()
|
||||
"VisionPrescription" -> VisionPrescription.serializer()
|
||||
else -> UnknownResource.serializer()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable(with = ResourceSerializer::class)
|
||||
@Stable
|
||||
open class Resource(
|
||||
var resourceType: String? = null,
|
||||
var id: String = "",
|
||||
)
|
||||
abstract class Resource {
|
||||
abstract val resourceType: String?
|
||||
abstract val id: String
|
||||
}
|
||||
|
||||
/** Fallback for any FHIR resourceType we don't model. */
|
||||
@Serializable
|
||||
@Stable
|
||||
class UnknownResource(
|
||||
override val resourceType: String? = null,
|
||||
override val id: String = "",
|
||||
) : Resource()
|
||||
|
||||
@Serializable
|
||||
@Stable
|
||||
class Practitioner(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
override val resourceType: String? = null,
|
||||
override val id: String = "",
|
||||
var active: Boolean? = null,
|
||||
var name: ArrayList<HumanName> = arrayListOf(),
|
||||
var gender: String? = null,
|
||||
) : Resource(resourceType, id)
|
||||
) : Resource()
|
||||
|
||||
@Serializable
|
||||
@Stable
|
||||
class Patient(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
override val resourceType: String? = null,
|
||||
override val id: String = "",
|
||||
var active: Boolean? = null,
|
||||
var name: ArrayList<HumanName> = arrayListOf(),
|
||||
var gender: String? = null,
|
||||
) : Resource(resourceType, id)
|
||||
) : Resource()
|
||||
|
||||
@Serializable
|
||||
@Stable
|
||||
class HumanName(
|
||||
var use: String? = null,
|
||||
@@ -75,19 +95,21 @@ class HumanName(
|
||||
fun assembleName(): String = given.joinToString(" ") + " " + family
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@Stable
|
||||
class Bundle(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
override val resourceType: String? = null,
|
||||
override val id: String = "",
|
||||
var type: String? = null,
|
||||
var created: String? = null,
|
||||
var entry: List<Resource> = arrayListOf(),
|
||||
) : Resource(resourceType, id)
|
||||
) : Resource()
|
||||
|
||||
@Serializable
|
||||
@Stable
|
||||
class VisionPrescription(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
override val resourceType: String? = null,
|
||||
override val id: String = "",
|
||||
var status: String? = null,
|
||||
var created: String? = null,
|
||||
var patient: Reference? = Reference(),
|
||||
@@ -95,7 +117,7 @@ class VisionPrescription(
|
||||
var dateWritten: String? = null,
|
||||
var prescriber: Reference? = Reference(),
|
||||
var lensSpecification: List<LensSpecification> = arrayListOf(),
|
||||
) : Resource(resourceType, id) {
|
||||
) : Resource() {
|
||||
fun glasses() = lensSpecification.filter { it.product == "lens" }
|
||||
|
||||
fun contacts() = lensSpecification.filter { it.product == "contacts" }
|
||||
@@ -109,6 +131,7 @@ class VisionPrescription(
|
||||
fun contactsLeftEyes() = lensSpecification.filter { it.product == "contacts" && it.eye == "left" }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@Stable
|
||||
class LensSpecification(
|
||||
var product: String? = null,
|
||||
@@ -129,12 +152,14 @@ class LensSpecification(
|
||||
var note: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@Stable
|
||||
class Prism(
|
||||
var amount: Double? = null,
|
||||
var base: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Reference(
|
||||
var reference: String? = null,
|
||||
)
|
||||
@@ -156,12 +181,22 @@ fun findReferenceInDb(
|
||||
}
|
||||
}
|
||||
|
||||
fun parseResourceBundleOrNull(json: String): FhirElementDatabase? {
|
||||
val mapper =
|
||||
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
/**
|
||||
* Lenient FHIR JSON reader: unknown keys are ignored (implementations routinely add
|
||||
* their own fields) and missing keys fall back to the property defaults, so we parse
|
||||
* as much of a resource as we can rather than rejecting the whole document.
|
||||
*/
|
||||
val FhirJson =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
return try {
|
||||
val resource = mapper.readValue<Resource>(json)
|
||||
fun parseResourceBundleOrNull(json: String): FhirElementDatabase? =
|
||||
try {
|
||||
val resource = FhirJson.decodeFromString(ResourceSerializer, json)
|
||||
|
||||
val db =
|
||||
when (resource) {
|
||||
@@ -182,4 +217,3 @@ fun parseResourceBundleOrNull(json: String): FhirElementDatabase? {
|
||||
Log.e("RenderEyeGlassesPrescription", "Parser error", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MiniFhirTest {
|
||||
@Test
|
||||
fun parsesVisionPrescriptionBundle() {
|
||||
val json =
|
||||
"""
|
||||
{"resourceType":"Bundle","id":"bundle-vision-test","type":"document","entry":[
|
||||
{"resourceType":"Practitioner","id":"2","active":true,"name":[{"use":"official","family":"Careful","given":["Adam"]}],"gender":"male"},
|
||||
{"resourceType":"Patient","id":"1","active":true,"name":[{"use":"official","family":"Duck","given":["Donald"]}],"gender":"male"},
|
||||
{"resourceType":"VisionPrescription","id":"3","status":"active","created":"2014-06-15","patient":{"reference":"#1"},"dateWritten":"2014-06-15","prescriber":{"reference":"#2"},"lensSpecification":[
|
||||
{"product":"lens","eye":"right","sphere":-2,"prism":{"amount":0.5,"base":"down"},"add":2},
|
||||
{"product":"lens","eye":"left","sphere":-1,"cylinder":-0.5,"axis":180,"prism":{"amount":0.5,"base":"up"},"add":2}
|
||||
]}
|
||||
]}
|
||||
""".trimIndent()
|
||||
|
||||
val result = parseResourceBundleOrNull(json)
|
||||
assertNotNull(result)
|
||||
|
||||
val bundle = result!!.baseResource as Bundle
|
||||
assertEquals(3, bundle.entry.size)
|
||||
|
||||
val patient = findReferenceInDb("#1", result.localDb) as Patient
|
||||
assertEquals("Donald Duck", patient.name.first().assembleName())
|
||||
|
||||
val prescriber = findReferenceInDb("#2", result.localDb) as Practitioner
|
||||
assertEquals("Adam Careful", prescriber.name.first().assembleName())
|
||||
|
||||
val vision = bundle.entry.filterIsInstance<VisionPrescription>().first()
|
||||
assertEquals(2, vision.glasses().size)
|
||||
assertEquals(-2.0, vision.glassesRightEyes().first().sphere!!, 0.001)
|
||||
assertEquals(
|
||||
"down",
|
||||
vision
|
||||
.glassesRightEyes()
|
||||
.first()
|
||||
.prism
|
||||
?.base,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresUnknownAndExtraFields() {
|
||||
// resourceType we don't model + extra top-level and nested fields that many
|
||||
// implementations add. Should still parse the known bits, not throw.
|
||||
val json =
|
||||
"""
|
||||
{"resourceType":"VisionPrescription","id":"7","status":"active","meta":{"versionId":"1"},
|
||||
"extraField":"whatever","lensSpecification":[
|
||||
{"eye":"right","sphere":-1.25,"vendorSpecific":{"foo":"bar"},"tags":["a","b"]}
|
||||
]}
|
||||
""".trimIndent()
|
||||
|
||||
val result = parseResourceBundleOrNull(json)
|
||||
assertNotNull(result)
|
||||
|
||||
val vision = result!!.baseResource as VisionPrescription
|
||||
assertEquals("active", vision.status)
|
||||
assertEquals(-1.25, vision.lensSpecification.first().sphere!!, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallsBackForUnknownResourceType() {
|
||||
val json = """{"resourceType":"Observation","id":"obs-1","valueQuantity":{"value":9.5}}"""
|
||||
|
||||
val result = parseResourceBundleOrNull(json)
|
||||
assertNotNull(result)
|
||||
|
||||
val base = result!!.baseResource
|
||||
assertTrue(base is UnknownResource)
|
||||
assertEquals("obs-1", base!!.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsUnknownEntriesInsideBundle() {
|
||||
// A Bundle mixing a known and an unknown resource must not fail wholesale.
|
||||
val json =
|
||||
"""
|
||||
{"resourceType":"Bundle","id":"b","entry":[
|
||||
{"resourceType":"Observation","id":"obs","code":{"text":"bp"}},
|
||||
{"resourceType":"Patient","id":"p","name":[{"family":"Doe","given":["Jane"]}]}
|
||||
]}
|
||||
""".trimIndent()
|
||||
|
||||
val result = parseResourceBundleOrNull(json)
|
||||
assertNotNull(result)
|
||||
|
||||
val bundle = result!!.baseResource as Bundle
|
||||
assertEquals(2, bundle.entry.size)
|
||||
assertTrue(bundle.entry.first() is UnknownResource)
|
||||
|
||||
val patient = findReferenceInDb("p", result.localDb) as Patient
|
||||
assertEquals("Jane Doe", patient.name.first().assembleName())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returnsNullOnGarbage() {
|
||||
assertNull(parseResourceBundleOrNull("not json at all"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user