From f26650b3d7f9818bd7fd7cedd3fa5e23d4d780d2 Mon Sep 17 00:00:00 2001 From: Jonathan Wilkinson Date: Mon, 27 Oct 2025 13:55:03 +0000 Subject: [PATCH] fix: implement resource hydration using Jasminb ResourceConverter --- .../resource/FormSubmissionVersion.kt | 131 +++++++++--------- .../com/ctrlhub/core/media/response/Image.kt | 2 +- .../FormSubmissionVersionResourcesTest.kt | 6 +- 3 files changed, 72 insertions(+), 67 deletions(-) diff --git a/src/main/kotlin/com/ctrlhub/core/datacapture/resource/FormSubmissionVersion.kt b/src/main/kotlin/com/ctrlhub/core/datacapture/resource/FormSubmissionVersion.kt index 27c362c..cfc71c9 100644 --- a/src/main/kotlin/com/ctrlhub/core/datacapture/resource/FormSubmissionVersion.kt +++ b/src/main/kotlin/com/ctrlhub/core/datacapture/resource/FormSubmissionVersion.kt @@ -15,6 +15,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.ObjectMapper import com.github.jasminb.jsonapi.StringIdHandler +import com.github.jasminb.jsonapi.ResourceConverter import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Meta import com.github.jasminb.jsonapi.annotations.Relationship @@ -83,73 +84,90 @@ class FormSubmissionVersion @JsonCreator constructor( private fun resourceMapper(): ObjectMapper = JsonConfig.getMapper() /** - * Convert the raw resources list (List>) into typed JsonApiEnvelope objects. - * This keeps the original raw structure but provides a typed view over it. + * Create a configured Jasminb ResourceConverter for the given target class and included classes. + * Also include any classes registered in the companion type registry so the converter knows + * about all registered resource types. */ - fun resourcesAsEnvelopes(): List = resources?.mapNotNull { res -> + private fun resourceConverter(targetClass: Class<*>, vararg includedClasses: Class<*>): ResourceConverter { + // Use the registered classes plus the explicit target and included classes. + val registryClasses: List> = companionTypeRegistryClasses() + + val classesList: MutableList> = ArrayList() + classesList.add(targetClass) + // add registry classes (skip duplicates) + for (c in registryClasses) { + if (!classesList.contains(c)) classesList.add(c) + } + // add explicitly passed included classes + for (c in includedClasses) { + if (!classesList.contains(c)) classesList.add(c) + } + + val classesArray: Array> = classesList.toTypedArray() + val rc = ResourceConverter(resourceMapper(), *classesArray) try { - resourceMapper().convertValue(res, JsonApiEnvelope::class.java) - } catch (e: Exception) { - null + rc.enableSerializationOption(com.github.jasminb.jsonapi.SerializationFeature.INCLUDE_RELATIONSHIP_ATTRIBUTES) + } catch (_: Throwable) { + // ignore } - } ?: emptyList() + return rc + } - /** - * Find the full envelope for a resource by id. - */ - fun findResourceEnvelopeById(id: String): JsonApiEnvelope? = resourcesAsEnvelopes().firstOrNull { it.data?.id == id } + // Helper to access companion's registry as a list (keeps the converter creation tidy) + private fun companionTypeRegistryClasses(): List> = synchronized(Companion) { + getAllRegisteredClasses() + } /** - * Find the inner resource data object by id. + * Find the raw JSON:API envelope Map for a resource by id. */ - fun findResourceDataById(id: String): JsonApiResourceData? = findResourceEnvelopeById(id)?.data + private fun findRawResourceEnvelopeById(id: String): Map? { + resources?.forEach { res -> + val data = res["data"] as? Map<*, *> ?: return@forEach + val dataId = data["id"] as? String + if (dataId == id) { + @Suppress("UNCHECKED_CAST") + return res + } + } + return null + } /** - * Hydrate the attributes of a resource (by id) into a target class using Jackson. - * Example: hydrateResourceAttributesById("...", Image::class.java) - * Returns null if resource or attributes are missing or conversion fails. + * Hydrate a resource by using Jasminb's ResourceConverter. Serialises the raw envelope Map + * to bytes using the configured ObjectMapper and passes it to ResourceConverter.readDocument. */ - fun hydrateResourceAttributesById(id: String, clazz: Class): T? { - val attrs = findResourceDataById(id)?.attributes ?: return null + private fun hydrateResourceUsingConverter(id: String, clazz: Class, vararg includedClasses: Class<*>): T? { + val envelope = findRawResourceEnvelopeById(id) ?: return null + return try { - resourceMapper().convertValue(attrs, clazz) + val bytes = resourceMapper().writeValueAsBytes(envelope) + val rc = resourceConverter(clazz, *includedClasses) + val document = rc.readDocument(bytes, clazz) + document.get() } catch (e: Exception) { - null + throw e } } /** - * Auto-hydrate a resource by looking up its JSON:API type and using the registered class for that type. - * Returns the hydrated object or null if not registered or conversion fails. + * Hydrate a resource (by id) into a target class using Jasminb ResourceConverter. + * This is the primary public method for hydration. */ - fun autoHydrateById(id: String): Any? { - val env = findResourceEnvelopeById(id) ?: return null - val type = env.data?.type ?: return null - val clazz = getRegisteredClass(type) ?: return null - return hydrateResourceAttributesById(id, clazz) + fun hydrateResourceById(id: String, clazz: Class): T? { + return hydrateResourceUsingConverter(id, clazz) } /** - * Reified convenience that attempts to auto-hydrate and cast to the expected type. + * Auto-hydrate a resource by looking up its JSON:API type and using the registered class for that type. */ - inline fun autoHydrateByIdAs(id: String): T? { - val any = autoHydrateById(id) ?: return null - return any as? T - } + fun autoHydrateById(id: String): Any? { + val envelope = findRawResourceEnvelopeById(id) ?: return null - /** - * Backwards-compatible helper: original simple lookup that returns the inner "data" map. - */ - fun findResourceById(id: String): Map? { - resources?.forEach { res -> - val data = res["data"] as? Map<*, *> ?: return@forEach - val dataId = data["id"] as? String - if (dataId == id) { - @Suppress("UNCHECKED_CAST") - return data as Map - } - } - return null + val data = envelope["data"] as? Map<*, *> ?: return null + val type = data["type"] as? String ?: return null + val clazz = getRegisteredClass(type) ?: return null + return hydrateResourceUsingConverter(id, clazz) } companion object { @@ -161,6 +179,11 @@ class FormSubmissionVersion @JsonCreator constructor( } fun getRegisteredClass(type: String): Class<*>? = typeRegistry[type] + + // expose a synchronized way to get the registry contents as a List + fun getAllRegisteredClasses(): List> = synchronized(typeRegistry) { + typeRegistry.values.toList() + } } } @@ -170,21 +193,3 @@ class FormSubmissionVersionMeta( @JsonProperty("latest") val latest: String? = null, @JsonProperty("is_latest") val isLatest: Boolean? = null, ) - -/** - * Lightweight typed representations for JSON:API resource envelopes and resource data. - * These are intentionally simple (attributes are a Map) to support arbitrary resource types. - */ -@JsonIgnoreProperties(ignoreUnknown = true) -data class JsonApiResourceData( - @JsonProperty("id") val id: String? = null, - @JsonProperty("type") val type: String? = null, - @JsonProperty("attributes") val attributes: Map? = null, - @JsonProperty("relationships") val relationships: Map? = null, -) - -@JsonIgnoreProperties(ignoreUnknown = true) -data class JsonApiEnvelope( - @JsonProperty("data") val data: JsonApiResourceData? = null, - @JsonProperty("jsonapi") val jsonapi: Map? = null, -) diff --git a/src/main/kotlin/com/ctrlhub/core/media/response/Image.kt b/src/main/kotlin/com/ctrlhub/core/media/response/Image.kt index 3ec6b31..28a6b79 100644 --- a/src/main/kotlin/com/ctrlhub/core/media/response/Image.kt +++ b/src/main/kotlin/com/ctrlhub/core/media/response/Image.kt @@ -9,7 +9,7 @@ import com.github.jasminb.jsonapi.annotations.Type @Type("images") class Image @JsonCreator constructor( - @Id(StringIdHandler::class) var id: String = "", + @JsonProperty("id") @Id(StringIdHandler::class) var id: String = "", @JsonProperty("mime_type") var mimeType: String = "", @JsonProperty("extension") var extension: String = "", @JsonProperty("width") var width: Int = 0, diff --git a/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionResourcesTest.kt b/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionResourcesTest.kt index fe7a9ac..915e8b9 100644 --- a/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionResourcesTest.kt +++ b/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionResourcesTest.kt @@ -21,7 +21,7 @@ import kotlin.test.assertNotNull class FormSubmissionVersionResourcesTest { @Test - fun `can auto-hydrate resources from resources envelope`() { + fun `can auto-hydrate resources`() { val jsonFilePath = Paths.get("src/test/resources/datacapture/one-form-submission-version-with-resources.json") val jsonContent = Files.readString(jsonFilePath) @@ -48,7 +48,7 @@ class FormSubmissionVersionResourcesTest { assertNotNull(result.id) // hydrate image resource - val image = result.autoHydrateByIdAs("c2d3e4f5-2a34-4b8c-e39d-0e1f2a3b4c5d") + val image = result.hydrateResourceById("c2d3e4f5-2a34-4b8c-e39d-0e1f2a3b4c5d", Image::class.java) assertNotNull(image) assertEquals("image/jpeg", image!!.mimeType) assertEquals(4000, image.width) @@ -56,7 +56,7 @@ class FormSubmissionVersionResourcesTest { assertEquals(2136986L, image.bytes) // hydrate operation resource - val op = result.autoHydrateByIdAs("d2e3f4a5-3b45-4c9d-f4ae-1f2a3b4c5d6e") + val op = result.hydrateResourceById("d2e3f4a5-3b45-4c9d-f4ae-1f2a3b4c5d6e", Operation::class.java) assertNotNull(op) assertEquals("Task 2", op!!.name) assertEquals("TK0002", op.code)