refactor: move MiniFhir/MedicalData parsing from Jackson to Kotlin Serialization

Replace the Jackson-based FHIR resource parser with kotlinx.serialization.
The `resourceType` polymorphism is now handled by a JsonContentPolymorphicSerializer
that dispatches to the modeled types (Practitioner, Patient, Bundle,
VisionPrescription) and falls back to a new UnknownResource for anything else,
so a Bundle mixing known and unknown resources still parses.

The reader is lenient (ignoreUnknownKeys, isLenient, explicitNulls=false,
coerceInputValues) so we parse what we can and tolerate the missing or extra
fields that many FHIR implementations add.

Adds MiniFhirTest covering the vision-prescription bundle, extra/unknown field
tolerance, unknown-resourceType fallback, mixed bundles, and garbage input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUeR8YFNHvBELCaLRDTpCn
This commit is contained in:
Claude
2026-07-17 21:27:31 +00:00
parent 56c99bb7b6
commit 1daa871d95
2 changed files with 199 additions and 38 deletions
@@ -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"))
}
}