diff --git a/build.gradle.kts b/build.gradle.kts index 93ad14c..53db15e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 { diff --git a/src/main/kotlin/com/ctrlhub/core/datacapture/FormSchemasRouter.kt b/src/main/kotlin/com/ctrlhub/core/datacapture/FormSchemasRouter.kt index 85892ae..343643d 100644 --- a/src/main/kotlin/com/ctrlhub/core/datacapture/FormSchemasRouter.kt +++ b/src/main/kotlin/com/ctrlhub/core/datacapture/FormSchemasRouter.kt @@ -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"); @@ -45,18 +38,7 @@ class FormSchemasRouter(httpClient: HttpClient) : Router(httpClient) { ): PaginatedList { val endpoint = "/v3/orgs/$organisationId/data-capture/forms/$formId/schemas" - val response = performGet(endpoint, requestParameters.toMap()) - val jsonContent = Json.parseToJsonElement(response.body()).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( @@ -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()).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 diff --git a/src/main/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouter.kt b/src/main/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouter.kt index a882ec2..902b879 100644 --- a/src/main/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouter.kt +++ b/src/main/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouter.kt @@ -64,7 +64,6 @@ class FormSubmissionVersionsRouter(httpClient: HttpClient) : Router(httpClient) id = "", schema = FormSchema( id = schemaId, - rawSchema = null, ) ), queryParameters = emptyMap(), diff --git a/src/main/kotlin/com/ctrlhub/core/datacapture/response/FormSchema.kt b/src/main/kotlin/com/ctrlhub/core/datacapture/response/FormSchema.kt index d528c14..431ac1b 100644 --- a/src/main/kotlin/com/ctrlhub/core/datacapture/response/FormSchema.kt +++ b/src/main/kotlin/com/ctrlhub/core/datacapture/response/FormSchema.kt @@ -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 @@ -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? = null, + @JsonProperty("view") + val view: Map? = null, + @JsonProperty("version") + val version: String? = null, @Meta var meta: FormSchemaMeta? = null, -) +) { + val rawSchema: String + get() { + val mapper = JsonConfig.getMapper() + + val attributes = mutableMapOf() + 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( diff --git a/src/main/kotlin/com/ctrlhub/core/json/JsonConfig.kt b/src/main/kotlin/com/ctrlhub/core/json/JsonConfig.kt new file mode 100644 index 0000000..a5346dc --- /dev/null +++ b/src/main/kotlin/com/ctrlhub/core/json/JsonConfig.kt @@ -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()) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ctrlhub/core/router/Router.kt b/src/main/kotlin/com/ctrlhub/core/router/Router.kt index f29ff99..6cf7622 100644 --- a/src/main/kotlin/com/ctrlhub/core/router/Router.kt +++ b/src/main/kotlin/com/ctrlhub/core/router/Router.kt @@ -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 = emptyMap()): HttpResponse { @@ -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 fetchPaginatedJsonApiResources( diff --git a/src/test/kotlin/com/ctrlhub/core/datacapture/FormSchemaRawSchemaTest.kt b/src/test/kotlin/com/ctrlhub/core/datacapture/FormSchemaRawSchemaTest.kt new file mode 100644 index 0000000..77e5d9c --- /dev/null +++ b/src/test/kotlin/com/ctrlhub/core/datacapture/FormSchemaRawSchemaTest.kt @@ -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 + val attributesMap = mapper.convertValue(attributesNode, object : TypeReference>() {}) + + val model = attributesMap["model"] as? Map + val view = attributesMap["view"] as? Map + 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")) + } +} diff --git a/src/test/resources/datacapture/form-schema-sample.json b/src/test/resources/datacapture/form-schema-sample.json new file mode 100644 index 0000000..f5a22b3 --- /dev/null +++ b/src/test/resources/datacapture/form-schema-sample.json @@ -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" } +} +