Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -83,73 +84,90 @@ class FormSubmissionVersion @JsonCreator constructor(
private fun resourceMapper(): ObjectMapper = JsonConfig.getMapper()

/**
* Convert the raw resources list (List<Map<...>>) 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<JsonApiEnvelope> = 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<Class<*>> = companionTypeRegistryClasses()

val classesList: MutableList<Class<*>> = 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<Class<*>> = 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<Class<*>> = 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<String, Any>? {
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 <T> hydrateResourceAttributesById(id: String, clazz: Class<T>): T? {
val attrs = findResourceDataById(id)?.attributes ?: return null
private fun <T> hydrateResourceUsingConverter(id: String, clazz: Class<T>, 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 <T> hydrateResourceById(id: String, clazz: Class<T>): 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 <reified T> 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<String, Any>? {
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<String, Any>
}
}
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 {
Expand All @@ -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<Class<*>> = synchronized(typeRegistry) {
typeRegistry.values.toList()
}
}
}

Expand All @@ -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<String, Any>? = null,
@JsonProperty("relationships") val relationships: Map<String, Any>? = null,
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class JsonApiEnvelope(
@JsonProperty("data") val data: JsonApiResourceData? = null,
@JsonProperty("jsonapi") val jsonapi: Map<String, Any>? = null,
)
2 changes: 1 addition & 1 deletion src/main/kotlin/com/ctrlhub/core/media/response/Image.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -48,15 +48,15 @@ class FormSubmissionVersionResourcesTest {
assertNotNull(result.id)

// hydrate image resource
val image = result.autoHydrateByIdAs<Image>("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)
assertEquals(3000, image.height)
assertEquals(2136986L, image.bytes)

// hydrate operation resource
val op = result.autoHydrateByIdAs<Operation>("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)
Expand Down
Loading