Skip to content

Commit f26650b

Browse files
committed
fix: implement resource hydration using Jasminb ResourceConverter
1 parent 7276921 commit f26650b

File tree

3 files changed

+72
-67
lines changed

3 files changed

+72
-67
lines changed

src/main/kotlin/com/ctrlhub/core/datacapture/resource/FormSubmissionVersion.kt

Lines changed: 68 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
1515
import com.fasterxml.jackson.annotation.JsonProperty
1616
import com.fasterxml.jackson.databind.ObjectMapper
1717
import com.github.jasminb.jsonapi.StringIdHandler
18+
import com.github.jasminb.jsonapi.ResourceConverter
1819
import com.github.jasminb.jsonapi.annotations.Id
1920
import com.github.jasminb.jsonapi.annotations.Meta
2021
import com.github.jasminb.jsonapi.annotations.Relationship
@@ -83,73 +84,90 @@ class FormSubmissionVersion @JsonCreator constructor(
8384
private fun resourceMapper(): ObjectMapper = JsonConfig.getMapper()
8485

8586
/**
86-
* Convert the raw resources list (List<Map<...>>) into typed JsonApiEnvelope objects.
87-
* This keeps the original raw structure but provides a typed view over it.
87+
* Create a configured Jasminb ResourceConverter for the given target class and included classes.
88+
* Also include any classes registered in the companion type registry so the converter knows
89+
* about all registered resource types.
8890
*/
89-
fun resourcesAsEnvelopes(): List<JsonApiEnvelope> = resources?.mapNotNull { res ->
91+
private fun resourceConverter(targetClass: Class<*>, vararg includedClasses: Class<*>): ResourceConverter {
92+
// Use the registered classes plus the explicit target and included classes.
93+
val registryClasses: List<Class<*>> = companionTypeRegistryClasses()
94+
95+
val classesList: MutableList<Class<*>> = ArrayList()
96+
classesList.add(targetClass)
97+
// add registry classes (skip duplicates)
98+
for (c in registryClasses) {
99+
if (!classesList.contains(c)) classesList.add(c)
100+
}
101+
// add explicitly passed included classes
102+
for (c in includedClasses) {
103+
if (!classesList.contains(c)) classesList.add(c)
104+
}
105+
106+
val classesArray: Array<Class<*>> = classesList.toTypedArray()
107+
val rc = ResourceConverter(resourceMapper(), *classesArray)
90108
try {
91-
resourceMapper().convertValue(res, JsonApiEnvelope::class.java)
92-
} catch (e: Exception) {
93-
null
109+
rc.enableSerializationOption(com.github.jasminb.jsonapi.SerializationFeature.INCLUDE_RELATIONSHIP_ATTRIBUTES)
110+
} catch (_: Throwable) {
111+
// ignore
94112
}
95-
} ?: emptyList()
113+
return rc
114+
}
96115

97-
/**
98-
* Find the full envelope for a resource by id.
99-
*/
100-
fun findResourceEnvelopeById(id: String): JsonApiEnvelope? = resourcesAsEnvelopes().firstOrNull { it.data?.id == id }
116+
// Helper to access companion's registry as a list (keeps the converter creation tidy)
117+
private fun companionTypeRegistryClasses(): List<Class<*>> = synchronized(Companion) {
118+
getAllRegisteredClasses()
119+
}
101120

102121
/**
103-
* Find the inner resource data object by id.
122+
* Find the raw JSON:API envelope Map for a resource by id.
104123
*/
105-
fun findResourceDataById(id: String): JsonApiResourceData? = findResourceEnvelopeById(id)?.data
124+
private fun findRawResourceEnvelopeById(id: String): Map<String, Any>? {
125+
resources?.forEach { res ->
126+
val data = res["data"] as? Map<*, *> ?: return@forEach
127+
val dataId = data["id"] as? String
128+
if (dataId == id) {
129+
@Suppress("UNCHECKED_CAST")
130+
return res
131+
}
132+
}
133+
return null
134+
}
106135

107136
/**
108-
* Hydrate the attributes of a resource (by id) into a target class using Jackson.
109-
* Example: hydrateResourceAttributesById("...", Image::class.java)
110-
* Returns null if resource or attributes are missing or conversion fails.
137+
* Hydrate a resource by using Jasminb's ResourceConverter. Serialises the raw envelope Map
138+
* to bytes using the configured ObjectMapper and passes it to ResourceConverter.readDocument.
111139
*/
112-
fun <T> hydrateResourceAttributesById(id: String, clazz: Class<T>): T? {
113-
val attrs = findResourceDataById(id)?.attributes ?: return null
140+
private fun <T> hydrateResourceUsingConverter(id: String, clazz: Class<T>, vararg includedClasses: Class<*>): T? {
141+
val envelope = findRawResourceEnvelopeById(id) ?: return null
142+
114143
return try {
115-
resourceMapper().convertValue(attrs, clazz)
144+
val bytes = resourceMapper().writeValueAsBytes(envelope)
145+
val rc = resourceConverter(clazz, *includedClasses)
146+
val document = rc.readDocument(bytes, clazz)
147+
document.get()
116148
} catch (e: Exception) {
117-
null
149+
throw e
118150
}
119151
}
120152

121153
/**
122-
* Auto-hydrate a resource by looking up its JSON:API type and using the registered class for that type.
123-
* Returns the hydrated object or null if not registered or conversion fails.
154+
* Hydrate a resource (by id) into a target class using Jasminb ResourceConverter.
155+
* This is the primary public method for hydration.
124156
*/
125-
fun autoHydrateById(id: String): Any? {
126-
val env = findResourceEnvelopeById(id) ?: return null
127-
val type = env.data?.type ?: return null
128-
val clazz = getRegisteredClass(type) ?: return null
129-
return hydrateResourceAttributesById(id, clazz)
157+
fun <T> hydrateResourceById(id: String, clazz: Class<T>): T? {
158+
return hydrateResourceUsingConverter(id, clazz)
130159
}
131160

132161
/**
133-
* Reified convenience that attempts to auto-hydrate and cast to the expected type.
162+
* Auto-hydrate a resource by looking up its JSON:API type and using the registered class for that type.
134163
*/
135-
inline fun <reified T> autoHydrateByIdAs(id: String): T? {
136-
val any = autoHydrateById(id) ?: return null
137-
return any as? T
138-
}
164+
fun autoHydrateById(id: String): Any? {
165+
val envelope = findRawResourceEnvelopeById(id) ?: return null
139166

140-
/**
141-
* Backwards-compatible helper: original simple lookup that returns the inner "data" map.
142-
*/
143-
fun findResourceById(id: String): Map<String, Any>? {
144-
resources?.forEach { res ->
145-
val data = res["data"] as? Map<*, *> ?: return@forEach
146-
val dataId = data["id"] as? String
147-
if (dataId == id) {
148-
@Suppress("UNCHECKED_CAST")
149-
return data as Map<String, Any>
150-
}
151-
}
152-
return null
167+
val data = envelope["data"] as? Map<*, *> ?: return null
168+
val type = data["type"] as? String ?: return null
169+
val clazz = getRegisteredClass(type) ?: return null
170+
return hydrateResourceUsingConverter(id, clazz)
153171
}
154172

155173
companion object {
@@ -161,6 +179,11 @@ class FormSubmissionVersion @JsonCreator constructor(
161179
}
162180

163181
fun getRegisteredClass(type: String): Class<*>? = typeRegistry[type]
182+
183+
// expose a synchronized way to get the registry contents as a List
184+
fun getAllRegisteredClasses(): List<Class<*>> = synchronized(typeRegistry) {
185+
typeRegistry.values.toList()
186+
}
164187
}
165188
}
166189

@@ -170,21 +193,3 @@ class FormSubmissionVersionMeta(
170193
@JsonProperty("latest") val latest: String? = null,
171194
@JsonProperty("is_latest") val isLatest: Boolean? = null,
172195
)
173-
174-
/**
175-
* Lightweight typed representations for JSON:API resource envelopes and resource data.
176-
* These are intentionally simple (attributes are a Map) to support arbitrary resource types.
177-
*/
178-
@JsonIgnoreProperties(ignoreUnknown = true)
179-
data class JsonApiResourceData(
180-
@JsonProperty("id") val id: String? = null,
181-
@JsonProperty("type") val type: String? = null,
182-
@JsonProperty("attributes") val attributes: Map<String, Any>? = null,
183-
@JsonProperty("relationships") val relationships: Map<String, Any>? = null,
184-
)
185-
186-
@JsonIgnoreProperties(ignoreUnknown = true)
187-
data class JsonApiEnvelope(
188-
@JsonProperty("data") val data: JsonApiResourceData? = null,
189-
@JsonProperty("jsonapi") val jsonapi: Map<String, Any>? = null,
190-
)

src/main/kotlin/com/ctrlhub/core/media/response/Image.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import com.github.jasminb.jsonapi.annotations.Type
99

1010
@Type("images")
1111
class Image @JsonCreator constructor(
12-
@Id(StringIdHandler::class) var id: String = "",
12+
@JsonProperty("id") @Id(StringIdHandler::class) var id: String = "",
1313
@JsonProperty("mime_type") var mimeType: String = "",
1414
@JsonProperty("extension") var extension: String = "",
1515
@JsonProperty("width") var width: Int = 0,

src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionResourcesTest.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import kotlin.test.assertNotNull
2121
class FormSubmissionVersionResourcesTest {
2222

2323
@Test
24-
fun `can auto-hydrate resources from resources envelope`() {
24+
fun `can auto-hydrate resources`() {
2525
val jsonFilePath = Paths.get("src/test/resources/datacapture/one-form-submission-version-with-resources.json")
2626
val jsonContent = Files.readString(jsonFilePath)
2727

@@ -48,15 +48,15 @@ class FormSubmissionVersionResourcesTest {
4848
assertNotNull(result.id)
4949

5050
// hydrate image resource
51-
val image = result.autoHydrateByIdAs<Image>("c2d3e4f5-2a34-4b8c-e39d-0e1f2a3b4c5d")
51+
val image = result.hydrateResourceById("c2d3e4f5-2a34-4b8c-e39d-0e1f2a3b4c5d", Image::class.java)
5252
assertNotNull(image)
5353
assertEquals("image/jpeg", image!!.mimeType)
5454
assertEquals(4000, image.width)
5555
assertEquals(3000, image.height)
5656
assertEquals(2136986L, image.bytes)
5757

5858
// hydrate operation resource
59-
val op = result.autoHydrateByIdAs<Operation>("d2e3f4a5-3b45-4c9d-f4ae-1f2a3b4c5d6e")
59+
val op = result.hydrateResourceById("d2e3f4a5-3b45-4c9d-f4ae-1f2a3b4c5d6e", Operation::class.java)
6060
assertNotNull(op)
6161
assertEquals("Task 2", op!!.name)
6262
assertEquals("TK0002", op.code)

0 commit comments

Comments
 (0)