diff --git a/src/main/kotlin/com/ctrlhub/core/Api.kt b/src/main/kotlin/com/ctrlhub/core/Api.kt index b8a2e58..962bd94 100644 --- a/src/main/kotlin/com/ctrlhub/core/Api.kt +++ b/src/main/kotlin/com/ctrlhub/core/Api.kt @@ -3,6 +3,7 @@ package com.ctrlhub.core import com.ctrlhub.core.http.KtorClientFactory import io.ktor.client.* import io.ktor.client.plugins.defaultRequest +import com.ctrlhub.core.ResourceTypeRegistry /** * The facade object through which interaction with the API occurs. @@ -10,6 +11,10 @@ import io.ktor.client.plugins.defaultRequest class Api( var httpClient: HttpClient = KtorClientFactory.create() ) { + init { + ResourceTypeRegistry.registerDefaults() + } + fun withHttpClientConfig(config: HttpClientConfig<*>.() -> Unit) { httpClient = KtorClientFactory.create(configBlock = config) } diff --git a/src/main/kotlin/com/ctrlhub/core/ResourceTypeRegistry.kt b/src/main/kotlin/com/ctrlhub/core/ResourceTypeRegistry.kt new file mode 100644 index 0000000..2867cd3 --- /dev/null +++ b/src/main/kotlin/com/ctrlhub/core/ResourceTypeRegistry.kt @@ -0,0 +1,63 @@ +package com.ctrlhub.core + +import com.ctrlhub.core.assets.equipment.exposures.resource.EquipmentExposureResource +import com.ctrlhub.core.assets.equipment.resource.EquipmentCategory +import com.ctrlhub.core.assets.equipment.resource.EquipmentItem +import com.ctrlhub.core.assets.equipment.resource.EquipmentManufacturer +import com.ctrlhub.core.assets.equipment.resource.EquipmentModel +import com.ctrlhub.core.assets.vehicles.resource.Vehicle +import com.ctrlhub.core.assets.vehicles.resource.VehicleCategory +import com.ctrlhub.core.assets.vehicles.resource.VehicleManufacturer +import com.ctrlhub.core.assets.vehicles.resource.VehicleModel +import com.ctrlhub.core.assets.vehicles.resource.VehicleSpecification +import com.ctrlhub.core.datacapture.resource.FormSubmissionVersion +import com.ctrlhub.core.datacapture.response.Form +import com.ctrlhub.core.datacapture.response.FormSchema +import com.ctrlhub.core.datacapture.response.FormSubmission +import com.ctrlhub.core.geo.Property +import com.ctrlhub.core.iam.response.User +import com.ctrlhub.core.media.response.Image +import com.ctrlhub.core.projects.appointments.response.Appointment +import com.ctrlhub.core.projects.operations.response.Operation + +/** + * Centralised place to register JSON:API resource `type` -> Kotlin class mappings. + * Call `ResourceTypeRegistry.registerDefaults()` during SDK initialization or in tests. + */ +object ResourceTypeRegistry { + fun registerDefaults() { + // media + FormSubmissionVersion.registerResourceType("images", Image::class.java) + + // operations / projects + FormSubmissionVersion.registerResourceType("operations", Operation::class.java) + FormSubmissionVersion.registerResourceType("appointments", Appointment::class.java) + + // users / iam + FormSubmissionVersion.registerResourceType("users", User::class.java) + + // datacapture + FormSubmissionVersion.registerResourceType("forms", Form::class.java) + FormSubmissionVersion.registerResourceType("form-schemas", FormSchema::class.java) + FormSubmissionVersion.registerResourceType("form-submissions", FormSubmission::class.java) + FormSubmissionVersion.registerResourceType("form-submission-versions", FormSubmissionVersion::class.java) + + // properties / geo + FormSubmissionVersion.registerResourceType("properties", Property::class.java) + + // vehicles + FormSubmissionVersion.registerResourceType("vehicles", Vehicle::class.java) + FormSubmissionVersion.registerResourceType("vehicle-categories", VehicleCategory::class.java) + FormSubmissionVersion.registerResourceType("vehicle-specifications", VehicleSpecification::class.java) + FormSubmissionVersion.registerResourceType("vehicle-manufacturers", VehicleManufacturer::class.java) + FormSubmissionVersion.registerResourceType("vehicle-models", VehicleModel::class.java) + + // equipment + FormSubmissionVersion.registerResourceType("equipment-items", EquipmentItem::class.java) + FormSubmissionVersion.registerResourceType("equipment-models", EquipmentModel::class.java) + FormSubmissionVersion.registerResourceType("equipment-categories", EquipmentCategory::class.java) + FormSubmissionVersion.registerResourceType("equipment-manufacturers", EquipmentManufacturer::class.java) + FormSubmissionVersion.registerResourceType("equipment-exposures", EquipmentExposureResource::class.java) + } +} + 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 38223b6..091a89a 100644 --- a/src/main/kotlin/com/ctrlhub/core/datacapture/resource/FormSubmissionVersion.kt +++ b/src/main/kotlin/com/ctrlhub/core/datacapture/resource/FormSubmissionVersion.kt @@ -11,6 +11,9 @@ import com.ctrlhub.core.projects.workorders.response.WorkOrder import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.kotlinModule import com.github.jasminb.jsonapi.StringIdHandler import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Meta @@ -59,7 +62,100 @@ class FormSubmissionVersion @JsonCreator constructor( @Relationship("payload_schemes") var payloadSchemes: List? = null, -) + + // raw JSON:API resource envelopes as returned in the response (kept as Map for backward compatibility) + @JsonProperty("resources") + var resources: List>? = null, +) { + + // shared Jackson mapper configured to ignore unknown properties when hydrating attribute maps + private fun resourceMapper(): ObjectMapper = Companion.mapper + + /** + * Convert the raw resources list (List>) into typed JsonApiEnvelope objects. + * This keeps the original raw structure but provides a typed view over it. + */ + fun resourcesAsEnvelopes(): List = resources?.mapNotNull { res -> + try { + resourceMapper().convertValue(res, JsonApiEnvelope::class.java) + } catch (e: Exception) { + null + } + } ?: emptyList() + + /** + * Find the full envelope for a resource by id. + */ + fun findResourceEnvelopeById(id: String): JsonApiEnvelope? = resourcesAsEnvelopes().firstOrNull { it.data?.id == id } + + /** + * Find the inner resource data object by id. + */ + fun findResourceDataById(id: String): JsonApiResourceData? = findResourceEnvelopeById(id)?.data + + /** + * 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. + */ + fun hydrateResourceAttributesById(id: String, clazz: Class): T? { + val attrs = findResourceDataById(id)?.attributes ?: return null + return try { + resourceMapper().convertValue(attrs, clazz) + } catch (e: Exception) { + null + } + } + + /** + * 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. + */ + fun autoHydrateById(id: String): Any? { + val env = findResourceEnvelopeById(id) ?: return null + val type = env.data?.type ?: return null + val clazz = Companion.getRegisteredClass(type) ?: return null + return hydrateResourceAttributesById(id, clazz) + } + + /** + * Reified convenience that attempts to auto-hydrate and cast to the expected type. + */ + inline fun autoHydrateByIdAs(id: String): T? { + val any = autoHydrateById(id) ?: return null + return any as? T + } + + /** + * 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 + } + + companion object { + private val mapper: ObjectMapper = ObjectMapper() + .registerModule(kotlinModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + // simple type registry mapping jsonapi resource "type" -> Class + private val typeRegistry: MutableMap> = mutableMapOf() + + fun registerResourceType(type: String, clazz: Class<*>) { + typeRegistry[type] = clazz + } + + fun getRegisteredClass(type: String): Class<*>? = typeRegistry[type] + } +} @JsonIgnoreProperties(ignoreUnknown = true) class FormSubmissionVersionMeta( @@ -67,3 +163,21 @@ 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/test/kotlin/com/ctrlhub/core/HttpClientUtils.kt b/src/test/kotlin/com/ctrlhub/core/HttpClientUtils.kt index d1aa8c2..53bad01 100644 --- a/src/test/kotlin/com/ctrlhub/core/HttpClientUtils.kt +++ b/src/test/kotlin/com/ctrlhub/core/HttpClientUtils.kt @@ -1,5 +1,6 @@ package com.ctrlhub.core +import com.ctrlhub.core.ResourceTypeRegistry import io.ktor.client.HttpClient import io.ktor.client.plugins.UserAgent import io.ktor.client.plugins.contentnegotiation.ContentNegotiation @@ -10,6 +11,8 @@ import io.ktor.util.appendIfNameAbsent import kotlinx.serialization.json.Json fun HttpClient.configureForTest(): HttpClient { + // ensure test environment has default resource type registrations + ResourceTypeRegistry.registerDefaults() return this.config { defaultRequest { headers.appendIfNameAbsent(HttpHeaders.ContentType, "application/json") diff --git a/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionResourcesTest.kt b/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionResourcesTest.kt new file mode 100644 index 0000000..fe7a9ac --- /dev/null +++ b/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionResourcesTest.kt @@ -0,0 +1,65 @@ +package com.ctrlhub.core.datacapture + +import com.ctrlhub.core.configureForTest +import com.ctrlhub.core.datacapture.resource.FormSubmissionVersion +import com.ctrlhub.core.media.response.Image +import com.ctrlhub.core.projects.operations.response.Operation +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.http.HttpHeaders +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Paths +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull + +class FormSubmissionVersionResourcesTest { + + @Test + fun `can auto-hydrate resources from resources envelope`() { + val jsonFilePath = Paths.get("src/test/resources/datacapture/one-form-submission-version-with-resources.json") + val jsonContent = Files.readString(jsonFilePath) + + val mockEngine = MockEngine { _ -> + respond( + content = jsonContent, + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/vnd.api+json") + ) + } + + val router = FormSubmissionVersionsRouter(httpClient = HttpClient(mockEngine).configureForTest()) + + // resource type mappings are registered centrally by configureForTest() / Api + + runBlocking { + val result = router.one( + organisationId = "b5c6d7e8-3a45-46f7-90a1-1b2c3d4e5f60", + submissionId = "e5f6a7b9-6d78-49ca-2d34-4e5f60718293", + versionId = "a1f9b6c2-3d5a-4a9e-9c1b-0f2e7a4d6b1c" + ) + + assertIs(result) + assertNotNull(result.id) + + // hydrate image resource + val image = result.autoHydrateByIdAs("c2d3e4f5-2a34-4b8c-e39d-0e1f2a3b4c5d") + 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("d2e3f4a5-3b45-4c9d-f4ae-1f2a3b4c5d6e") + assertNotNull(op) + assertEquals("Task 2", op!!.name) + assertEquals("TK0002", op.code) + } + } +} diff --git a/src/test/resources/datacapture/one-form-submission-version-with-resources.json b/src/test/resources/datacapture/one-form-submission-version-with-resources.json new file mode 100644 index 0000000..0bafc73 --- /dev/null +++ b/src/test/resources/datacapture/one-form-submission-version-with-resources.json @@ -0,0 +1,350 @@ +{ + "data": { + "id": "a1f9b6c2-3d5a-4a9e-9c1b-0f2e7a4d6b1c", + "type": "form-submission-versions", + "attributes": { + "iteration": 1, + "payload": { + "b1c2d3e4-5f60-4a1b-9c2d-3e4f5a6b7c8d": "2025-07-17T13:20:00Z", + "c1d2e3f4-6a70-4b2c-8d3e-4f5a6b7c8d9e": "d2e3f4a5-3b45-4c9d-f4ae-1f2a3b4c5d6e", + "d1e2f3a4-7b80-4c3d-9e4f-5a6b7c8d9e0f": "2025-07-17T13:20:00Z", + "e1f2a3b4-8c90-4d4e-ad5f-6a7b8c9d0e1f": "e2f3a4b5-4c56-4d0e-a5bf-2a3b4c5d6e7f", + "f1a2b3c4-9d01-4e5f-b06a-7b8c9d0e1f2a": true, + "a2b3c4d5-0e12-4f6a-c17b-8c9d0e1f2a3b": true, + "b2c3d4e5-1f23-4a7b-d28c-9d0e1f2a3b4c": [ + "c2d3e4f5-2a34-4b8c-e39d-0e1f2a3b4c5d" + ] + }, + "resources": [ + { + "data": { + "id": "c2d3e4f5-2a34-4b8c-e39d-0e1f2a3b4c5d", + "type": "images", + "attributes": { + "bytes": 2136986, + "dimensions": [], + "extension": ".jpg", + "height": 3000, + "mime_type": "image/jpeg", + "width": 4000 + }, + "relationships": { + "author": { + "data": { + "id": "e2f3a4b5-4c56-4d0e-a5bf-2a3b4c5d6e7f", + "type": "authors" + } + }, + "organisation": { + "data": { + "id": "b5c6d7e8-3a45-46f7-90a1-1b2c3d4e5f60", + "type": "organisations" + } + } + }, + "meta": { + "links": [ + { + "url": "https://example.com/media/images/c2d3e4f5-2a34-4b8c-e39d-0e1f2a3b4c5d/original.jpg", + "width": 4000, + "height": 3000 + } + ], + "created_at": "2025-07-17T12:20:59.063Z" + } + }, + "jsonapi": { + "version": "1.0" + } + }, + { + "data": { + "id": "d2e3f4a5-3b45-4c9d-f4ae-1f2a3b4c5d6e", + "type": "operations", + "attributes": { + "code": "TK0002", + "dates": { + "scheduled": {} + }, + "description": "", + "done_reason": "", + "labels": [ + { + "key": "Time band", + "value": "AM" + } + ], + "name": "Task 2", + "requirements": { + "forms": [ + { + "id": "c5d6e7f8-4b56-47a8-0b12-2c3d4e5f6071", + "required": true + } + ] + }, + "status": "" + }, + "relationships": { + "appointment": { + "data": null + }, + "assignees": { + "data": [ + { + "id": "f2a3b4c5-5d67-4e1f-b6c0-3b4c5d6e7f80", + "type": "users" + }, + { + "id": "a3b4c5d6-6e78-4f2a-c7d1-4c5d6e7f8091", + "type": "users" + }, + { + "id": "b3c4d5e6-7f89-4a3b-d8e2-5d6e7f8091a2", + "type": "users" + }, + { + "id": "e2f3a4b5-4c56-4d0e-a5bf-2a3b4c5d6e7f", + "type": "users" + } + ] + }, + "forms": { + "data": [ + { + "id": "c5d6e7f8-4b56-47a8-0b12-2c3d4e5f6071", + "type": "forms" + } + ] + }, + "organisation": { + "data": { + "id": "b5c6d7e8-3a45-46f7-90a1-1b2c3d4e5f60", + "type": "organisations" + } + }, + "permits": { + "data": [] + }, + "properties": { + "data": [ + { + "id": "f2a3b4c5-5d67-4e1f-b6c0-3b4c5d6e7f80", + "type": "properties" + }, + { + "id": "a3b4c5d6-6e78-4f2a-c7d1-4c5d6e7f8091", + "type": "properties" + }, + { + "id": "b3c4d5e6-7f89-4a3b-d8e2-5d6e7f8091a2", + "type": "properties" + }, + { + "id": "c3d4e5f6-809a-4b4c-e9f3-6e7f8091a2b3", + "type": "properties" + }, + { + "id": "d3e4f5a6-91ab-4c5d-fa04-7f8091a2b3c4", + "type": "properties" + }, + { + "id": "e3f4a5b6-a2bc-4d6e-0b15-8091a2b3c4d5", + "type": "properties" + }, + { + "id": "f3a4b5c6-b3cd-4e7f-1c26-91a2b3c4d5e6", + "type": "properties" + } + ] + }, + "scheme": { + "data": { + "id": "a4b5c6d7-c4de-4f80-2d37-a2b3c4d5e6f7", + "type": "schemes" + } + }, + "streets": { + "data": [] + }, + "teams": { + "data": [] + }, + "template": { + "data": null + }, + "work_order": { + "data": { + "id": "b4c5d6e7-d5ef-4091-3e48-b3c4d5e6f7a8", + "type": "work-orders" + } + } + }, + "meta": { + "created_at": "2025-03-13T13:42:37.472Z", + "updated_at": "2025-07-16T10:51:36.892Z", + "counts": { + "properties": 7, + "streets": 0 + } + } + }, + "jsonapi": { + "version": "1.0" + } + }, + { + "data": { + "id": "e2f3a4b5-4c56-4d0e-a5bf-2a3b4c5d6e7f", + "type": "users", + "attributes": { + "email": "lorem.ipsum@example.com", + "identities": [ + { + "id": "c4d5e6f7-e6f0-41a2-4f59-c4d5e6f7a8b9", + "platform": "cadent", + "meta": { + "organisation_id": "f6a7b8c9-7e89-4a1b-9c2d-5e6f70819203" + } + }, + { + "id": "d4e5f6a7-f701-42b3-5c67-d5e6f7a8b9c0", + "platform": "ngn", + "meta": { + "organisation_id": "a6b7c8d9-8f90-4b2c-8d3e-6f7081920345" + } + }, + { + "id": "e4f5a6b7-0712-43c4-6d78-e6f7a8b9c0d1", + "platform": "multi_tenant", + "meta": { + "organisation_id": "b6c7d8e9-9012-4c3d-9e4f-70819203456a" + } + }, + { + "id": "f4a5b6c7-1823-44d5-7e89-f7a8b9c0d1e2", + "platform": "kratos", + "meta": {} + }, + { + "id": "a5b6c7d8-2934-45e6-8f90-0a1b2c3d4e5f", + "platform": "multi_tenant", + "meta": null + } + ], + "profile": { + "address": { + "area": "", + "country_code": "GB", + "county": "Lorem County", + "name": "Lorem House", + "number": "", + "postcode": "LO1 1RL", + "street": "Lorem Street", + "town": "Ipsum", + "what3words": "" + }, + "contact": { + "landline": "", + "mobile": "" + }, + "personal": { + "dob": "", + "first_name": "Lorem", + "last_name": "Ipsum" + }, + "settings": { + "preferred_language": "en-GB", + "timezone": "Europe/London" + }, + "work": { + "cscs": "", + "eusr": "", + "occupation": "Ctrl Hub", + "start_date": "2024-10-10" + } + } + } + }, + "jsonapi": { + "version": "1.0" + } + } + ] + }, + "relationships": { + "author": { + "data": { + "id": "e2f3a4b5-4c56-4d0e-a5bf-2a3b4c5d6e7f", + "type": "users" + } + }, + "form": { + "data": { + "id": "c5d6e7f8-4b56-47a8-0b12-2c3d4e5f6071", + "type": "forms" + } + }, + "organisation": { + "data": { + "id": "b5c6d7e8-3a45-46f7-90a1-1b2c3d4e5f60", + "type": "organisations" + } + }, + "payload_images": { + "data": [ + { + "id": "c2d3e4f5-2a34-4b8c-e39d-0e1f2a3b4c5d", + "type": "images" + } + ] + }, + "payload_operations": { + "data": [ + { + "id": "d2e3f4a5-3b45-4c9d-f4ae-1f2a3b4c5d6e", + "type": "operations" + } + ] + }, + "payload_properties": { + "data": [] + }, + "payload_schemes": { + "data": [] + }, + "payload_users": { + "data": [ + { + "id": "e2f3a4b5-4c56-4d0e-a5bf-2a3b4c5d6e7f", + "type": "users" + } + ] + }, + "payload_work_orders": { + "data": [] + }, + "schema": { + "data": { + "id": "d5e6f7a8-5c67-48b9-1c23-3d4e5f607182", + "type": "form-schemas" + } + }, + "submission": { + "data": { + "id": "e5f6a7b9-6d78-49ca-2d34-4e5f60718293", + "type": "form-submissions" + } + } + }, + "meta": { + "created_at": "2025-07-17T12:21:00.017Z", + "latest": "a1f9b6c2-3d5a-4a9e-9c1b-0f2e7a4d6b1c", + "is_latest": true + } + }, + "jsonapi": { + "version": "1.0" + } +} +