Skip to content

Commit 2846cc5

Browse files
authored
Merge pull request #20 from ctrl-hub/fix/form-schema-hydration
fix: update form schema hydration to hydrate via the JSON API library…
2 parents 1e59a22 + 4da599b commit 2846cc5

File tree

8 files changed

+172
-88
lines changed

8 files changed

+172
-88
lines changed

build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ dependencies {
3030
testImplementation(kotlin("test"))
3131
testImplementation(libs.mockk)
3232
testImplementation(libs.ktor.client.mock)
33+
34+
// Ensure JUnit Jupiter engine is available for useJUnitPlatform()
35+
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
3336
}
3437

3538
val generateBuildConfig by tasks.registering {

src/main/kotlin/com/ctrlhub/core/datacapture/FormSchemasRouter.kt

Lines changed: 2 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,11 @@ package com.ctrlhub.core.datacapture
33
import com.ctrlhub.core.Api
44
import com.ctrlhub.core.api.response.PaginatedList
55
import com.ctrlhub.core.datacapture.response.FormSchema
6-
import com.ctrlhub.core.datacapture.response.FormSchemaLatestMeta
7-
import com.ctrlhub.core.datacapture.response.FormSchemaMeta
8-
import com.ctrlhub.core.extractPaginationFromMeta
96
import com.ctrlhub.core.router.Router
107
import com.ctrlhub.core.router.request.FilterOption
118
import com.ctrlhub.core.router.request.JsonApiIncludes
129
import com.ctrlhub.core.router.request.RequestParametersWithIncludes
1310
import io.ktor.client.HttpClient
14-
import io.ktor.client.call.body
15-
import kotlinx.serialization.json.*
16-
import java.time.ZonedDateTime
17-
import java.time.format.DateTimeFormatter
1811

1912
enum class FormSchemaIncludes(val value: String) : JsonApiIncludes {
2013
Xsources("x-sources");
@@ -45,18 +38,7 @@ class FormSchemasRouter(httpClient: HttpClient) : Router(httpClient) {
4538
): PaginatedList<FormSchema> {
4639
val endpoint = "/v3/orgs/$organisationId/data-capture/forms/$formId/schemas"
4740

48-
val response = performGet(endpoint, requestParameters.toMap())
49-
val jsonContent = Json.parseToJsonElement(response.body<String>()).jsonObject
50-
51-
val dataArray = jsonContent["data"]?.jsonArray ?: JsonArray(emptyList())
52-
val formSchemas = dataArray.mapNotNull { item ->
53-
item.jsonObjectOrNull()?.let { instantiateFormSchemaFromJson(it) }
54-
}
55-
56-
return PaginatedList(
57-
data = formSchemas,
58-
pagination = extractPaginationFromMeta(jsonContent)
59-
)
41+
return fetchPaginatedJsonApiResources(endpoint, requestParameters.toMap())
6042
}
6143

6244
suspend fun one(
@@ -67,47 +49,8 @@ class FormSchemasRouter(httpClient: HttpClient) : Router(httpClient) {
6749
): FormSchema {
6850
val endpoint = "/v3/orgs/$organisationId/data-capture/forms/$formId/schemas/$schemaId"
6951

70-
val response = performGet(endpoint, requestParameters.toMap())
71-
val jsonContent = Json.parseToJsonElement(response.body<String>()).jsonObjectOrNull()
72-
?: throw IllegalStateException("Missing JSON content")
73-
74-
return instantiateFormSchemaFromJson(jsonContent["data"]!!.jsonObject)
75-
}
76-
77-
private fun instantiateFormSchemaFromJson(json: JsonObject): FormSchema {
78-
val id = json["id"]?.jsonPrimitive?.content
79-
?: throw IllegalStateException("Missing id")
80-
81-
val rawContent = json.toString()
82-
val isoFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME
83-
84-
val formSchemaMeta = json["meta"]?.jsonObject?.let { it ->
85-
val createdAtStr = it["created_at"]?.jsonPrimitive?.content
86-
val updatedAtStr = it["updated_at"]?.jsonPrimitive?.contentOrNull
87-
88-
FormSchemaMeta(
89-
createdAt = createdAtStr?.let { ZonedDateTime.parse(it, isoFormatter).toLocalDateTime() }
90-
?: throw IllegalStateException("Missing created_at"),
91-
updatedAt = updatedAtStr?.takeIf { it.isNotEmpty() }?.let {
92-
ZonedDateTime.parse(it, isoFormatter).toLocalDateTime()
93-
},
94-
latest = it["latest"]?.let { latestJson ->
95-
FormSchemaLatestMeta(
96-
id = latestJson.jsonObject["id"]?.jsonPrimitive?.content ?: "",
97-
version = latestJson.jsonObject["version"]?.jsonPrimitive?.content ?: "",
98-
)
99-
}
100-
)
101-
}
102-
103-
return FormSchema(
104-
id = id,
105-
rawSchema = rawContent,
106-
meta = formSchemaMeta,
107-
)
52+
return fetchJsonApiResource(endpoint, requestParameters.toMap())
10853
}
109-
110-
private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject
11154
}
11255

11356
val Api.formSchemas: FormSchemasRouter

src/main/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouter.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ class FormSubmissionVersionsRouter(httpClient: HttpClient) : Router(httpClient)
6464
id = "",
6565
schema = FormSchema(
6666
id = schemaId,
67-
rawSchema = null,
6867
)
6968
),
7069
queryParameters = emptyMap(),

src/main/kotlin/com/ctrlhub/core/datacapture/response/FormSchema.kt

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package com.ctrlhub.core.datacapture.response
22

3+
import com.ctrlhub.core.json.JsonConfig
34
import com.fasterxml.jackson.annotation.JsonCreator
4-
import com.fasterxml.jackson.annotation.JsonIgnore
55
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
66
import com.fasterxml.jackson.annotation.JsonProperty
77
import com.github.jasminb.jsonapi.StringIdHandler
@@ -10,14 +10,43 @@ import com.github.jasminb.jsonapi.annotations.Meta
1010
import com.github.jasminb.jsonapi.annotations.Type
1111
import java.time.LocalDateTime
1212

13+
@JsonIgnoreProperties(ignoreUnknown = true)
1314
@Type("form-schemas")
14-
data class FormSchema(
15+
data class FormSchema @JsonCreator constructor(
16+
@JsonProperty("id")
1517
@Id(StringIdHandler::class)
1618
val id: String? = null,
17-
@JsonIgnore val rawSchema: String? = null,
19+
@JsonProperty("model")
20+
val model: Map<String, Any>? = null,
21+
@JsonProperty("view")
22+
val view: Map<String, Any>? = null,
23+
@JsonProperty("version")
24+
val version: String? = null,
1825
@Meta
1926
var meta: FormSchemaMeta? = null,
20-
)
27+
) {
28+
val rawSchema: String
29+
get() {
30+
val mapper = JsonConfig.getMapper()
31+
32+
val attributes = mutableMapOf<String, Any>()
33+
attributes["version"] = version ?: ""
34+
attributes["id"] = id ?: ""
35+
model?.let { attributes["model"] = it }
36+
view?.let { attributes["view"] = it }
37+
meta?.let { attributes["meta"] = it }
38+
39+
val dataMap = mutableMapOf(
40+
"id" to (id ?: ""),
41+
"type" to "form-schemas",
42+
"attributes" to attributes
43+
)
44+
45+
val envelope = mapOf("data" to dataMap)
46+
47+
return mapper.writeValueAsString(envelope)
48+
}
49+
}
2150

2251
@JsonIgnoreProperties(ignoreUnknown = true)
2352
data class FormSchemaMeta @JsonCreator constructor(
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.ctrlhub.core.json
2+
3+
import com.ctrlhub.core.serializer.JacksonLocalDateTimeDeserializer
4+
import com.ctrlhub.core.serializer.JacksonLocalDateTimeSerializer
5+
import com.fasterxml.jackson.databind.ObjectMapper
6+
import com.fasterxml.jackson.databind.module.SimpleModule
7+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
8+
import com.fasterxml.jackson.module.kotlin.kotlinModule
9+
import java.time.LocalDateTime
10+
11+
object JsonConfig {
12+
fun getMapper(): ObjectMapper {
13+
val module = SimpleModule().apply {
14+
addSerializer(LocalDateTime::class.java, JacksonLocalDateTimeSerializer())
15+
addDeserializer(LocalDateTime::class.java, JacksonLocalDateTimeDeserializer())
16+
}
17+
18+
return ObjectMapper().apply {
19+
registerModule(JavaTimeModule())
20+
registerModule(module)
21+
registerModule(kotlinModule())
22+
}
23+
}
24+
}

src/main/kotlin/com/ctrlhub/core/router/Router.kt

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,18 @@ package com.ctrlhub.core.router
33
import com.ctrlhub.core.api.ApiClientException
44
import com.ctrlhub.core.api.ApiException
55
import com.ctrlhub.core.api.UnauthorizedException
6-
import com.ctrlhub.core.api.response.CountsMeta
7-
import com.ctrlhub.core.api.response.OffsetsMeta
8-
import com.ctrlhub.core.api.response.PageMeta
9-
import com.ctrlhub.core.api.response.PaginatedList
10-
import com.ctrlhub.core.api.response.PaginationMeta
11-
import com.ctrlhub.core.api.response.RequestedMeta
12-
import com.ctrlhub.core.serializer.JacksonLocalDateTimeDeserializer
13-
import com.ctrlhub.core.serializer.JacksonLocalDateTimeSerializer
14-
import com.fasterxml.jackson.module.kotlin.kotlinModule
6+
import com.ctrlhub.core.api.response.*
7+
import com.ctrlhub.core.json.JsonConfig
158
import com.fasterxml.jackson.databind.ObjectMapper
16-
import com.fasterxml.jackson.databind.module.SimpleModule
17-
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
189
import com.github.jasminb.jsonapi.JSONAPIDocument
1910
import com.github.jasminb.jsonapi.ResourceConverter
2011
import com.github.jasminb.jsonapi.SerializationFeature
2112
import io.ktor.client.*
22-
import io.ktor.client.call.body
23-
import io.ktor.client.plugins.ClientRequestException
13+
import io.ktor.client.call.*
14+
import io.ktor.client.plugins.*
2415
import io.ktor.client.request.*
2516
import io.ktor.client.statement.*
2617
import io.ktor.http.*
27-
import java.time.LocalDateTime
2818

2919
abstract class Router(val httpClient: HttpClient) {
3020
protected suspend fun performGet(endpoint: String, queryString: Map<String, String> = emptyMap()): HttpResponse {
@@ -145,16 +135,7 @@ abstract class Router(val httpClient: HttpClient) {
145135
}
146136

147137
fun getObjectMapper(): ObjectMapper {
148-
val module = SimpleModule().apply {
149-
addSerializer(LocalDateTime::class.java, JacksonLocalDateTimeSerializer())
150-
addDeserializer(LocalDateTime::class.java, JacksonLocalDateTimeDeserializer())
151-
}
152-
153-
return ObjectMapper().apply {
154-
registerModule(JavaTimeModule())
155-
registerModule(module)
156-
registerModule(kotlinModule())
157-
}
138+
return JsonConfig.getMapper()
158139
}
159140

160141
protected suspend inline fun <reified T> fetchPaginatedJsonApiResources(
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.ctrlhub.core.datacapture
2+
3+
import com.ctrlhub.core.datacapture.response.FormSchema
4+
import com.ctrlhub.core.json.JsonConfig
5+
import com.fasterxml.jackson.core.type.TypeReference
6+
import java.nio.file.Files
7+
import java.nio.file.Paths
8+
import kotlin.test.Test
9+
import kotlin.test.assertEquals
10+
import kotlin.test.assertTrue
11+
12+
class FormSchemaRawSchemaTest {
13+
14+
@Test
15+
fun `rawSchema should produce jsonapi envelope with expected fields`() {
16+
val jsonFilePath = Paths.get("src/test/resources/datacapture/form-schema-sample.json")
17+
val jsonContent = Files.readString(jsonFilePath)
18+
19+
val mapper = JsonConfig.getMapper()
20+
val root = mapper.readTree(jsonContent)
21+
val dataNode = root.get("data")
22+
val id = dataNode.get("id").asText()
23+
24+
val attributesNode = dataNode.get("attributes")
25+
// Convert attributes node to a Map<String, Any>
26+
val attributesMap = mapper.convertValue(attributesNode, object : TypeReference<Map<String, Any>>() {})
27+
28+
val model = attributesMap["model"] as? Map<String, Any>
29+
val view = attributesMap["view"] as? Map<String, Any>
30+
val version = attributesMap["version"] as? String
31+
32+
// Construct the FormSchema using the parsed pieces (meta omitted here)
33+
val fs = FormSchema(id = id, model = model, view = view, version = version, meta = null)
34+
val raw = fs.rawSchema
35+
36+
// Parse the generated JSON and assert structure
37+
val node = mapper.readTree(raw)
38+
39+
assertEquals(id, node["data"]["id"].asText())
40+
assertEquals("form-schemas", node["data"]["type"].asText())
41+
42+
val attributes = node["data"]["attributes"]
43+
assertEquals(version, attributes["version"].asText())
44+
assertTrue(attributes["model"]["properties"].has("field-1"))
45+
}
46+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"data": {
3+
"id": "83378c09-xxxx-xxxx-xxxx-b98ae0df67b2",
4+
"type": "form-schemas",
5+
"attributes": {
6+
"model": {
7+
"additionalProperties": false,
8+
"allOf": [],
9+
"properties": {
10+
"field-1": { "type": "string" },
11+
"field-2": {
12+
"items": { "format": "uuid", "type": "string" },
13+
"maxItems": 3,
14+
"minItems": 1,
15+
"type": "array",
16+
"uniqueItems": true,
17+
"x-source": { "resource-type": "images" }
18+
}
19+
},
20+
"required": ["field-1"],
21+
"type": "object"
22+
},
23+
"version": "22.0.0",
24+
"view": {
25+
"title": {"submit": [], "view": []},
26+
"sections": [
27+
{
28+
"title": {"submit": [{"language": "en-GB", "value": "Section A"}], "view": [{"language": "en-GB", "value": "Section A"}]},
29+
"rows": [
30+
{
31+
"columns": [
32+
{
33+
"blocks": [
34+
{ "id": "field-1", "order": 0, "title": {"submit": [], "view": []} },
35+
{ "id": "field-2", "order": 1, "title": {"submit": [], "view": []} }
36+
],
37+
"order": 0
38+
}
39+
],
40+
"order": 0
41+
}
42+
],
43+
"order": 0
44+
}
45+
]
46+
}
47+
},
48+
"relationships": {
49+
"author": { "data": { "id": "author-1", "type": "authors" } }
50+
},
51+
"meta": {
52+
"created_at": "2025-07-16T10:42:31.55Z",
53+
"updated_at": "2025-07-16T10:42:31.55Z",
54+
"latest": { "id": "706c34a1-xxxx-xxxx-xxxx-649ee35d4e71", "version": "29.0.0" }
55+
}
56+
},
57+
"jsonapi": { "version": "1.0" }
58+
}
59+

0 commit comments

Comments
 (0)