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
5 changes: 5 additions & 0 deletions src/main/kotlin/com/ctrlhub/core/Api.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ 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.
*/
class Api(
var httpClient: HttpClient = KtorClientFactory.create()
) {
init {
ResourceTypeRegistry.registerDefaults()
}

fun withHttpClientConfig(config: HttpClientConfig<*>.() -> Unit) {
httpClient = KtorClientFactory.create(configBlock = config)
}
Expand Down
63 changes: 63 additions & 0 deletions src/main/kotlin/com/ctrlhub/core/ResourceTypeRegistry.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -59,11 +62,122 @@ class FormSubmissionVersion @JsonCreator constructor(

@Relationship("payload_schemes")
var payloadSchemes: List<Scheme>? = null,
)

// raw JSON:API resource envelopes as returned in the response (kept as Map for backward compatibility)
@JsonProperty("resources")
var resources: List<Map<String, Any>>? = 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<Map<...>>) into typed JsonApiEnvelope objects.
* This keeps the original raw structure but provides a typed view over it.
*/
fun resourcesAsEnvelopes(): List<JsonApiEnvelope> = 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 <T> hydrateResourceAttributesById(id: String, clazz: Class<T>): 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 <reified T> 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<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
}

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<String, Class<*>> = mutableMapOf()

fun registerResourceType(type: String, clazz: Class<*>) {
typeRegistry[type] = clazz
}

fun getRegisteredClass(type: String): Class<*>? = typeRegistry[type]
}
}

@JsonIgnoreProperties(ignoreUnknown = true)
class FormSubmissionVersionMeta(
@JsonProperty("created_at") val createdAt: LocalDateTime? = null,
@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,
)
3 changes: 3 additions & 0 deletions src/test/kotlin/com/ctrlhub/core/HttpClientUtils.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<FormSubmissionVersion>(result)
assertNotNull(result.id)

// hydrate image resource
val image = result.autoHydrateByIdAs<Image>("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<Operation>("d2e3f4a5-3b45-4c9d-f4ae-1f2a3b4c5d6e")
assertNotNull(op)
assertEquals("Task 2", op!!.name)
assertEquals("TK0002", op.code)
}
}
}
Loading
Loading