Skip to content

Commit 5b34de4

Browse files
authored
Merge pull request #17 from ctrl-hub/feat/support-form-version-resources
feat: implement the ability to hydrate resources in a form submission…
2 parents 7cb3698 + 9d5d501 commit 5b34de4

File tree

6 files changed

+601
-1
lines changed

6 files changed

+601
-1
lines changed

src/main/kotlin/com/ctrlhub/core/Api.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@ package com.ctrlhub.core
33
import com.ctrlhub.core.http.KtorClientFactory
44
import io.ktor.client.*
55
import io.ktor.client.plugins.defaultRequest
6+
import com.ctrlhub.core.ResourceTypeRegistry
67

78
/**
89
* The facade object through which interaction with the API occurs.
910
*/
1011
class Api(
1112
var httpClient: HttpClient = KtorClientFactory.create()
1213
) {
14+
init {
15+
ResourceTypeRegistry.registerDefaults()
16+
}
17+
1318
fun withHttpClientConfig(config: HttpClientConfig<*>.() -> Unit) {
1419
httpClient = KtorClientFactory.create(configBlock = config)
1520
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.ctrlhub.core
2+
3+
import com.ctrlhub.core.assets.equipment.exposures.resource.EquipmentExposureResource
4+
import com.ctrlhub.core.assets.equipment.resource.EquipmentCategory
5+
import com.ctrlhub.core.assets.equipment.resource.EquipmentItem
6+
import com.ctrlhub.core.assets.equipment.resource.EquipmentManufacturer
7+
import com.ctrlhub.core.assets.equipment.resource.EquipmentModel
8+
import com.ctrlhub.core.assets.vehicles.resource.Vehicle
9+
import com.ctrlhub.core.assets.vehicles.resource.VehicleCategory
10+
import com.ctrlhub.core.assets.vehicles.resource.VehicleManufacturer
11+
import com.ctrlhub.core.assets.vehicles.resource.VehicleModel
12+
import com.ctrlhub.core.assets.vehicles.resource.VehicleSpecification
13+
import com.ctrlhub.core.datacapture.resource.FormSubmissionVersion
14+
import com.ctrlhub.core.datacapture.response.Form
15+
import com.ctrlhub.core.datacapture.response.FormSchema
16+
import com.ctrlhub.core.datacapture.response.FormSubmission
17+
import com.ctrlhub.core.geo.Property
18+
import com.ctrlhub.core.iam.response.User
19+
import com.ctrlhub.core.media.response.Image
20+
import com.ctrlhub.core.projects.appointments.response.Appointment
21+
import com.ctrlhub.core.projects.operations.response.Operation
22+
23+
/**
24+
* Centralised place to register JSON:API resource `type` -> Kotlin class mappings.
25+
* Call `ResourceTypeRegistry.registerDefaults()` during SDK initialization or in tests.
26+
*/
27+
object ResourceTypeRegistry {
28+
fun registerDefaults() {
29+
// media
30+
FormSubmissionVersion.registerResourceType("images", Image::class.java)
31+
32+
// operations / projects
33+
FormSubmissionVersion.registerResourceType("operations", Operation::class.java)
34+
FormSubmissionVersion.registerResourceType("appointments", Appointment::class.java)
35+
36+
// users / iam
37+
FormSubmissionVersion.registerResourceType("users", User::class.java)
38+
39+
// datacapture
40+
FormSubmissionVersion.registerResourceType("forms", Form::class.java)
41+
FormSubmissionVersion.registerResourceType("form-schemas", FormSchema::class.java)
42+
FormSubmissionVersion.registerResourceType("form-submissions", FormSubmission::class.java)
43+
FormSubmissionVersion.registerResourceType("form-submission-versions", FormSubmissionVersion::class.java)
44+
45+
// properties / geo
46+
FormSubmissionVersion.registerResourceType("properties", Property::class.java)
47+
48+
// vehicles
49+
FormSubmissionVersion.registerResourceType("vehicles", Vehicle::class.java)
50+
FormSubmissionVersion.registerResourceType("vehicle-categories", VehicleCategory::class.java)
51+
FormSubmissionVersion.registerResourceType("vehicle-specifications", VehicleSpecification::class.java)
52+
FormSubmissionVersion.registerResourceType("vehicle-manufacturers", VehicleManufacturer::class.java)
53+
FormSubmissionVersion.registerResourceType("vehicle-models", VehicleModel::class.java)
54+
55+
// equipment
56+
FormSubmissionVersion.registerResourceType("equipment-items", EquipmentItem::class.java)
57+
FormSubmissionVersion.registerResourceType("equipment-models", EquipmentModel::class.java)
58+
FormSubmissionVersion.registerResourceType("equipment-categories", EquipmentCategory::class.java)
59+
FormSubmissionVersion.registerResourceType("equipment-manufacturers", EquipmentManufacturer::class.java)
60+
FormSubmissionVersion.registerResourceType("equipment-exposures", EquipmentExposureResource::class.java)
61+
}
62+
}
63+

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

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import com.ctrlhub.core.projects.workorders.response.WorkOrder
1111
import com.fasterxml.jackson.annotation.JsonCreator
1212
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
1313
import com.fasterxml.jackson.annotation.JsonProperty
14+
import com.fasterxml.jackson.databind.DeserializationFeature
15+
import com.fasterxml.jackson.databind.ObjectMapper
16+
import com.fasterxml.jackson.module.kotlin.kotlinModule
1417
import com.github.jasminb.jsonapi.StringIdHandler
1518
import com.github.jasminb.jsonapi.annotations.Id
1619
import com.github.jasminb.jsonapi.annotations.Meta
@@ -59,11 +62,122 @@ class FormSubmissionVersion @JsonCreator constructor(
5962

6063
@Relationship("payload_schemes")
6164
var payloadSchemes: List<Scheme>? = null,
62-
)
65+
66+
// raw JSON:API resource envelopes as returned in the response (kept as Map for backward compatibility)
67+
@JsonProperty("resources")
68+
var resources: List<Map<String, Any>>? = null,
69+
) {
70+
71+
// shared Jackson mapper configured to ignore unknown properties when hydrating attribute maps
72+
private fun resourceMapper(): ObjectMapper = Companion.mapper
73+
74+
/**
75+
* Convert the raw resources list (List<Map<...>>) into typed JsonApiEnvelope objects.
76+
* This keeps the original raw structure but provides a typed view over it.
77+
*/
78+
fun resourcesAsEnvelopes(): List<JsonApiEnvelope> = resources?.mapNotNull { res ->
79+
try {
80+
resourceMapper().convertValue(res, JsonApiEnvelope::class.java)
81+
} catch (e: Exception) {
82+
null
83+
}
84+
} ?: emptyList()
85+
86+
/**
87+
* Find the full envelope for a resource by id.
88+
*/
89+
fun findResourceEnvelopeById(id: String): JsonApiEnvelope? = resourcesAsEnvelopes().firstOrNull { it.data?.id == id }
90+
91+
/**
92+
* Find the inner resource data object by id.
93+
*/
94+
fun findResourceDataById(id: String): JsonApiResourceData? = findResourceEnvelopeById(id)?.data
95+
96+
/**
97+
* Hydrate the attributes of a resource (by id) into a target class using Jackson.
98+
* Example: hydrateResourceAttributesById("...", Image::class.java)
99+
* Returns null if resource or attributes are missing or conversion fails.
100+
*/
101+
fun <T> hydrateResourceAttributesById(id: String, clazz: Class<T>): T? {
102+
val attrs = findResourceDataById(id)?.attributes ?: return null
103+
return try {
104+
resourceMapper().convertValue(attrs, clazz)
105+
} catch (e: Exception) {
106+
null
107+
}
108+
}
109+
110+
/**
111+
* Auto-hydrate a resource by looking up its JSON:API type and using the registered class for that type.
112+
* Returns the hydrated object or null if not registered or conversion fails.
113+
*/
114+
fun autoHydrateById(id: String): Any? {
115+
val env = findResourceEnvelopeById(id) ?: return null
116+
val type = env.data?.type ?: return null
117+
val clazz = Companion.getRegisteredClass(type) ?: return null
118+
return hydrateResourceAttributesById(id, clazz)
119+
}
120+
121+
/**
122+
* Reified convenience that attempts to auto-hydrate and cast to the expected type.
123+
*/
124+
inline fun <reified T> autoHydrateByIdAs(id: String): T? {
125+
val any = autoHydrateById(id) ?: return null
126+
return any as? T
127+
}
128+
129+
/**
130+
* Backwards-compatible helper: original simple lookup that returns the inner "data" map.
131+
*/
132+
fun findResourceById(id: String): Map<String, Any>? {
133+
resources?.forEach { res ->
134+
val data = res["data"] as? Map<*, *> ?: return@forEach
135+
val dataId = data["id"] as? String
136+
if (dataId == id) {
137+
@Suppress("UNCHECKED_CAST")
138+
return data as Map<String, Any>
139+
}
140+
}
141+
return null
142+
}
143+
144+
companion object {
145+
private val mapper: ObjectMapper = ObjectMapper()
146+
.registerModule(kotlinModule())
147+
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
148+
149+
// simple type registry mapping jsonapi resource "type" -> Class
150+
private val typeRegistry: MutableMap<String, Class<*>> = mutableMapOf()
151+
152+
fun registerResourceType(type: String, clazz: Class<*>) {
153+
typeRegistry[type] = clazz
154+
}
155+
156+
fun getRegisteredClass(type: String): Class<*>? = typeRegistry[type]
157+
}
158+
}
63159

64160
@JsonIgnoreProperties(ignoreUnknown = true)
65161
class FormSubmissionVersionMeta(
66162
@JsonProperty("created_at") val createdAt: LocalDateTime? = null,
67163
@JsonProperty("latest") val latest: String? = null,
68164
@JsonProperty("is_latest") val isLatest: Boolean? = null,
69165
)
166+
167+
/**
168+
* Lightweight typed representations for JSON:API resource envelopes and resource data.
169+
* These are intentionally simple (attributes are a Map) to support arbitrary resource types.
170+
*/
171+
@JsonIgnoreProperties(ignoreUnknown = true)
172+
data class JsonApiResourceData(
173+
@JsonProperty("id") val id: String? = null,
174+
@JsonProperty("type") val type: String? = null,
175+
@JsonProperty("attributes") val attributes: Map<String, Any>? = null,
176+
@JsonProperty("relationships") val relationships: Map<String, Any>? = null,
177+
)
178+
179+
@JsonIgnoreProperties(ignoreUnknown = true)
180+
data class JsonApiEnvelope(
181+
@JsonProperty("data") val data: JsonApiResourceData? = null,
182+
@JsonProperty("jsonapi") val jsonapi: Map<String, Any>? = null,
183+
)

src/test/kotlin/com/ctrlhub/core/HttpClientUtils.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.ctrlhub.core
22

3+
import com.ctrlhub.core.ResourceTypeRegistry
34
import io.ktor.client.HttpClient
45
import io.ktor.client.plugins.UserAgent
56
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
@@ -10,6 +11,8 @@ import io.ktor.util.appendIfNameAbsent
1011
import kotlinx.serialization.json.Json
1112

1213
fun HttpClient.configureForTest(): HttpClient {
14+
// ensure test environment has default resource type registrations
15+
ResourceTypeRegistry.registerDefaults()
1316
return this.config {
1417
defaultRequest {
1518
headers.appendIfNameAbsent(HttpHeaders.ContentType, "application/json")
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.ctrlhub.core.datacapture
2+
3+
import com.ctrlhub.core.configureForTest
4+
import com.ctrlhub.core.datacapture.resource.FormSubmissionVersion
5+
import com.ctrlhub.core.media.response.Image
6+
import com.ctrlhub.core.projects.operations.response.Operation
7+
import io.ktor.client.HttpClient
8+
import io.ktor.client.engine.mock.MockEngine
9+
import io.ktor.client.engine.mock.respond
10+
import io.ktor.http.HttpStatusCode
11+
import io.ktor.http.headersOf
12+
import io.ktor.http.HttpHeaders
13+
import kotlinx.coroutines.runBlocking
14+
import org.junit.jupiter.api.Test
15+
import java.nio.file.Files
16+
import java.nio.file.Paths
17+
import kotlin.test.assertEquals
18+
import kotlin.test.assertIs
19+
import kotlin.test.assertNotNull
20+
21+
class FormSubmissionVersionResourcesTest {
22+
23+
@Test
24+
fun `can auto-hydrate resources from resources envelope`() {
25+
val jsonFilePath = Paths.get("src/test/resources/datacapture/one-form-submission-version-with-resources.json")
26+
val jsonContent = Files.readString(jsonFilePath)
27+
28+
val mockEngine = MockEngine { _ ->
29+
respond(
30+
content = jsonContent,
31+
status = HttpStatusCode.OK,
32+
headers = headersOf(HttpHeaders.ContentType, "application/vnd.api+json")
33+
)
34+
}
35+
36+
val router = FormSubmissionVersionsRouter(httpClient = HttpClient(mockEngine).configureForTest())
37+
38+
// resource type mappings are registered centrally by configureForTest() / Api
39+
40+
runBlocking {
41+
val result = router.one(
42+
organisationId = "b5c6d7e8-3a45-46f7-90a1-1b2c3d4e5f60",
43+
submissionId = "e5f6a7b9-6d78-49ca-2d34-4e5f60718293",
44+
versionId = "a1f9b6c2-3d5a-4a9e-9c1b-0f2e7a4d6b1c"
45+
)
46+
47+
assertIs<FormSubmissionVersion>(result)
48+
assertNotNull(result.id)
49+
50+
// hydrate image resource
51+
val image = result.autoHydrateByIdAs<Image>("c2d3e4f5-2a34-4b8c-e39d-0e1f2a3b4c5d")
52+
assertNotNull(image)
53+
assertEquals("image/jpeg", image!!.mimeType)
54+
assertEquals(4000, image.width)
55+
assertEquals(3000, image.height)
56+
assertEquals(2136986L, image.bytes)
57+
58+
// hydrate operation resource
59+
val op = result.autoHydrateByIdAs<Operation>("d2e3f4a5-3b45-4c9d-f4ae-1f2a3b4c5d6e")
60+
assertNotNull(op)
61+
assertEquals("Task 2", op!!.name)
62+
assertEquals("TK0002", op.code)
63+
}
64+
}
65+
}

0 commit comments

Comments
 (0)