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
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ dependencies {
testImplementation(kotlin("test"))
testImplementation(libs.mockk)
testImplementation(libs.ktor.client.mock)

// Ensure JUnit Jupiter engine is available for useJUnitPlatform()
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
}

val generateBuildConfig by tasks.registering {
Expand Down
61 changes: 2 additions & 59 deletions src/main/kotlin/com/ctrlhub/core/datacapture/FormSchemasRouter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,11 @@ package com.ctrlhub.core.datacapture
import com.ctrlhub.core.Api
import com.ctrlhub.core.api.response.PaginatedList
import com.ctrlhub.core.datacapture.response.FormSchema
import com.ctrlhub.core.datacapture.response.FormSchemaLatestMeta
import com.ctrlhub.core.datacapture.response.FormSchemaMeta
import com.ctrlhub.core.extractPaginationFromMeta
import com.ctrlhub.core.router.Router
import com.ctrlhub.core.router.request.FilterOption
import com.ctrlhub.core.router.request.JsonApiIncludes
import com.ctrlhub.core.router.request.RequestParametersWithIncludes
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import kotlinx.serialization.json.*
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter

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

val response = performGet(endpoint, requestParameters.toMap())
val jsonContent = Json.parseToJsonElement(response.body<String>()).jsonObject

val dataArray = jsonContent["data"]?.jsonArray ?: JsonArray(emptyList())
val formSchemas = dataArray.mapNotNull { item ->
item.jsonObjectOrNull()?.let { instantiateFormSchemaFromJson(it) }
}

return PaginatedList(
data = formSchemas,
pagination = extractPaginationFromMeta(jsonContent)
)
return fetchPaginatedJsonApiResources(endpoint, requestParameters.toMap())
}

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

val response = performGet(endpoint, requestParameters.toMap())
val jsonContent = Json.parseToJsonElement(response.body<String>()).jsonObjectOrNull()
?: throw IllegalStateException("Missing JSON content")

return instantiateFormSchemaFromJson(jsonContent["data"]!!.jsonObject)
}

private fun instantiateFormSchemaFromJson(json: JsonObject): FormSchema {
val id = json["id"]?.jsonPrimitive?.content
?: throw IllegalStateException("Missing id")

val rawContent = json.toString()
val isoFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME

val formSchemaMeta = json["meta"]?.jsonObject?.let { it ->
val createdAtStr = it["created_at"]?.jsonPrimitive?.content
val updatedAtStr = it["updated_at"]?.jsonPrimitive?.contentOrNull

FormSchemaMeta(
createdAt = createdAtStr?.let { ZonedDateTime.parse(it, isoFormatter).toLocalDateTime() }
?: throw IllegalStateException("Missing created_at"),
updatedAt = updatedAtStr?.takeIf { it.isNotEmpty() }?.let {
ZonedDateTime.parse(it, isoFormatter).toLocalDateTime()
},
latest = it["latest"]?.let { latestJson ->
FormSchemaLatestMeta(
id = latestJson.jsonObject["id"]?.jsonPrimitive?.content ?: "",
version = latestJson.jsonObject["version"]?.jsonPrimitive?.content ?: "",
)
}
)
}

return FormSchema(
id = id,
rawSchema = rawContent,
meta = formSchemaMeta,
)
return fetchJsonApiResource(endpoint, requestParameters.toMap())
}

private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject
}

val Api.formSchemas: FormSchemasRouter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ class FormSubmissionVersionsRouter(httpClient: HttpClient) : Router(httpClient)
id = "",
schema = FormSchema(
id = schemaId,
rawSchema = null,
)
),
queryParameters = emptyMap(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.ctrlhub.core.datacapture.response

import com.ctrlhub.core.json.JsonConfig
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import com.github.jasminb.jsonapi.StringIdHandler
Expand All @@ -10,14 +10,43 @@ import com.github.jasminb.jsonapi.annotations.Meta
import com.github.jasminb.jsonapi.annotations.Type
import java.time.LocalDateTime

@JsonIgnoreProperties(ignoreUnknown = true)
@Type("form-schemas")
data class FormSchema(
data class FormSchema @JsonCreator constructor(
@JsonProperty("id")
@Id(StringIdHandler::class)
val id: String? = null,
@JsonIgnore val rawSchema: String? = null,
@JsonProperty("model")
val model: Map<String, Any>? = null,
@JsonProperty("view")
val view: Map<String, Any>? = null,
@JsonProperty("version")
val version: String? = null,
@Meta
var meta: FormSchemaMeta? = null,
)
) {
val rawSchema: String
get() {
val mapper = JsonConfig.getMapper()

val attributes = mutableMapOf<String, Any>()
attributes["version"] = version ?: ""
attributes["id"] = id ?: ""
model?.let { attributes["model"] = it }
view?.let { attributes["view"] = it }
meta?.let { attributes["meta"] = it }

val dataMap = mutableMapOf(
"id" to (id ?: ""),
"type" to "form-schemas",
"attributes" to attributes
)

val envelope = mapOf("data" to dataMap)

return mapper.writeValueAsString(envelope)
}
}

@JsonIgnoreProperties(ignoreUnknown = true)
data class FormSchemaMeta @JsonCreator constructor(
Expand Down
24 changes: 24 additions & 0 deletions src/main/kotlin/com/ctrlhub/core/json/JsonConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.ctrlhub.core.json

import com.ctrlhub.core.serializer.JacksonLocalDateTimeDeserializer
import com.ctrlhub.core.serializer.JacksonLocalDateTimeSerializer
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.kotlinModule
import java.time.LocalDateTime

object JsonConfig {
fun getMapper(): ObjectMapper {
val module = SimpleModule().apply {
addSerializer(LocalDateTime::class.java, JacksonLocalDateTimeSerializer())
addDeserializer(LocalDateTime::class.java, JacksonLocalDateTimeDeserializer())
}

return ObjectMapper().apply {
registerModule(JavaTimeModule())
registerModule(module)
registerModule(kotlinModule())
}
}
}
29 changes: 5 additions & 24 deletions src/main/kotlin/com/ctrlhub/core/router/Router.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,18 @@ package com.ctrlhub.core.router
import com.ctrlhub.core.api.ApiClientException
import com.ctrlhub.core.api.ApiException
import com.ctrlhub.core.api.UnauthorizedException
import com.ctrlhub.core.api.response.CountsMeta
import com.ctrlhub.core.api.response.OffsetsMeta
import com.ctrlhub.core.api.response.PageMeta
import com.ctrlhub.core.api.response.PaginatedList
import com.ctrlhub.core.api.response.PaginationMeta
import com.ctrlhub.core.api.response.RequestedMeta
import com.ctrlhub.core.serializer.JacksonLocalDateTimeDeserializer
import com.ctrlhub.core.serializer.JacksonLocalDateTimeSerializer
import com.fasterxml.jackson.module.kotlin.kotlinModule
import com.ctrlhub.core.api.response.*
import com.ctrlhub.core.json.JsonConfig
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.github.jasminb.jsonapi.JSONAPIDocument
import com.github.jasminb.jsonapi.ResourceConverter
import com.github.jasminb.jsonapi.SerializationFeature
import io.ktor.client.*
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.call.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import java.time.LocalDateTime

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

fun getObjectMapper(): ObjectMapper {
val module = SimpleModule().apply {
addSerializer(LocalDateTime::class.java, JacksonLocalDateTimeSerializer())
addDeserializer(LocalDateTime::class.java, JacksonLocalDateTimeDeserializer())
}

return ObjectMapper().apply {
registerModule(JavaTimeModule())
registerModule(module)
registerModule(kotlinModule())
}
return JsonConfig.getMapper()
}

protected suspend inline fun <reified T> fetchPaginatedJsonApiResources(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.ctrlhub.core.datacapture

import com.ctrlhub.core.datacapture.response.FormSchema
import com.ctrlhub.core.json.JsonConfig
import com.fasterxml.jackson.core.type.TypeReference
import java.nio.file.Files
import java.nio.file.Paths
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class FormSchemaRawSchemaTest {

@Test
fun `rawSchema should produce jsonapi envelope with expected fields`() {
val jsonFilePath = Paths.get("src/test/resources/datacapture/form-schema-sample.json")
val jsonContent = Files.readString(jsonFilePath)

val mapper = JsonConfig.getMapper()
val root = mapper.readTree(jsonContent)
val dataNode = root.get("data")
val id = dataNode.get("id").asText()

val attributesNode = dataNode.get("attributes")
// Convert attributes node to a Map<String, Any>
val attributesMap = mapper.convertValue(attributesNode, object : TypeReference<Map<String, Any>>() {})

val model = attributesMap["model"] as? Map<String, Any>
val view = attributesMap["view"] as? Map<String, Any>
val version = attributesMap["version"] as? String

// Construct the FormSchema using the parsed pieces (meta omitted here)
val fs = FormSchema(id = id, model = model, view = view, version = version, meta = null)
val raw = fs.rawSchema

// Parse the generated JSON and assert structure
val node = mapper.readTree(raw)

assertEquals(id, node["data"]["id"].asText())
assertEquals("form-schemas", node["data"]["type"].asText())

val attributes = node["data"]["attributes"]
assertEquals(version, attributes["version"].asText())
assertTrue(attributes["model"]["properties"].has("field-1"))
}
}
59 changes: 59 additions & 0 deletions src/test/resources/datacapture/form-schema-sample.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"data": {
"id": "83378c09-xxxx-xxxx-xxxx-b98ae0df67b2",
"type": "form-schemas",
"attributes": {
"model": {
"additionalProperties": false,
"allOf": [],
"properties": {
"field-1": { "type": "string" },
"field-2": {
"items": { "format": "uuid", "type": "string" },
"maxItems": 3,
"minItems": 1,
"type": "array",
"uniqueItems": true,
"x-source": { "resource-type": "images" }
}
},
"required": ["field-1"],
"type": "object"
},
"version": "22.0.0",
"view": {
"title": {"submit": [], "view": []},
"sections": [
{
"title": {"submit": [{"language": "en-GB", "value": "Section A"}], "view": [{"language": "en-GB", "value": "Section A"}]},
"rows": [
{
"columns": [
{
"blocks": [
{ "id": "field-1", "order": 0, "title": {"submit": [], "view": []} },
{ "id": "field-2", "order": 1, "title": {"submit": [], "view": []} }
],
"order": 0
}
],
"order": 0
}
],
"order": 0
}
]
}
},
"relationships": {
"author": { "data": { "id": "author-1", "type": "authors" } }
},
"meta": {
"created_at": "2025-07-16T10:42:31.55Z",
"updated_at": "2025-07-16T10:42:31.55Z",
"latest": { "id": "706c34a1-xxxx-xxxx-xxxx-649ee35d4e71", "version": "29.0.0" }
}
},
"jsonapi": { "version": "1.0" }
}

Loading