From 1daa871d95b38ee3fdb4cf42f8b50eba9ae33aed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 21:27:31 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MUeR8YFNHvBELCaLRDTpCn --- .../vitorpamplona/amethyst/model/MiniFhir.kt | 110 +++++++++------ .../amethyst/model/MiniFhirTest.kt | 127 ++++++++++++++++++ 2 files changed, 199 insertions(+), 38 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/MiniFhirTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MiniFhir.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MiniFhir.kt index e5c035f6f1..330f3c9485 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MiniFhir.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MiniFhir.kt @@ -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::class) { + override fun selectDeserializer(element: JsonElement): DeserializationStrategy = + 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 = 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 = 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 = 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 = 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(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 } -} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/MiniFhirTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/MiniFhirTest.kt new file mode 100644 index 0000000000..2c1449c24f --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/MiniFhirTest.kt @@ -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().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")) + } +}