diff --git a/build.gradle.kts b/build.gradle.kts index 13754276..7a3a112c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -101,6 +101,7 @@ kotlin { implementation("io.ktor:ktor-events:${property("ktorVersion")}") implementation("io.ktor:ktor-serialization-kotlinx-json:${property("ktorVersion")}") + implementation("io.ktor:ktor-serialization-kotlinx-xml:${property("ktorVersion")}") implementation("io.ktor:ktor-serialization-gson:${property("ktorVersion")}") implementation("io.ktor:ktor-serialization-jackson:${property("ktorVersion")}") diff --git a/src/commonMain/kotlin/constants/Endpoints.kt b/src/commonMain/kotlin/constants/Endpoints.kt index 709f0f8d..bade867f 100644 --- a/src/commonMain/kotlin/constants/Endpoints.kt +++ b/src/commonMain/kotlin/constants/Endpoints.kt @@ -1,6 +1,7 @@ package constants const val VALIDATION_ENDPOINT = "validate" +const val CONVERSION_ENDPOINT = "convert" const val VALIDATOR_VERSION_ENDPOINT = "validator/version" const val CONTEXT_ENDPOINT = "context" const val IG_ENDPOINT = "ig" diff --git a/src/commonMain/resources/static-content/openapi.yml b/src/commonMain/resources/static-content/openapi.yml index 418afb57..0fbab52e 100644 --- a/src/commonMain/resources/static-content/openapi.yml +++ b/src/commonMain/resources/static-content/openapi.yml @@ -61,6 +61,76 @@ paths: title: validatorVersionOK type: string example: "5.6.39" + /convert: + post: + tags: + - Convert a Resource + description: "Converts a resource." + operationId: ConvertAResource + produces: + - application/json + - application/xml + - application/fhir+json + - application/fhir+xml + requestBody: + required: true + content: + application/json: + schema: + type: object + application/fhir+json: + schema: + type: object + application/xml: + schema: + type: object + application/fhir+xml: + schema: + type: object + parameters: + - in: query + name: type + schema: + type: string + description: xml or json + - in: query + name: toType + schema: + type: string + description: xml or json + - in: query + name: version + schema: + type: string + description: source FHIR version (takes precedence over fhirVersion parameter of Content-Type header) + - in: query + name: toVersion + schema: + type: string + description: target FHIR version (takes precedence over fhirVersion parameter of Accept header) + responses: + "200": + description: OK + headers: + Content-Type: + schema: + type: string + "400": + description: Bad Request + content: + text/plain: + schema: + title: convertBadRequest + type: string + example: "Invalid toType parameter! Supported xml or json." + "500": + description: Internal Server Error + content: + text/plain: + schema: + title: convertInternalServerError + type: string + example: "Internal server error." /ig: get: tags: diff --git a/src/jvmMain/kotlin/Module.kt b/src/jvmMain/kotlin/Module.kt index f4ce4e2b..8e8fe278 100644 --- a/src/jvmMain/kotlin/Module.kt +++ b/src/jvmMain/kotlin/Module.kt @@ -1,5 +1,6 @@ import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.SerializationFeature +import controller.conversion.conversionModule import controller.debug.debugModule import controller.ig.igModule import controller.terminology.terminologyModule @@ -98,6 +99,7 @@ fun Application.setup() { versionModule() debugModule() validationModule() + conversionModule() terminologyModule() uptimeModule() diff --git a/src/jvmMain/kotlin/controller/ControllersInjection.kt b/src/jvmMain/kotlin/controller/ControllersInjection.kt index 1f7f0b55..fd31a547 100644 --- a/src/jvmMain/kotlin/controller/ControllersInjection.kt +++ b/src/jvmMain/kotlin/controller/ControllersInjection.kt @@ -1,5 +1,7 @@ package controller +import controller.conversion.ConversionController +import controller.conversion.ConversionControllerImpl import controller.ig.IgController import controller.ig.IgControllerImpl import controller.terminology.TerminologyController @@ -15,6 +17,7 @@ import org.koin.dsl.module object ControllersInjection { val koinBeans = module { single { ValidationControllerImpl() } + single { ConversionControllerImpl() } single { VersionControllerImpl() } single { IgControllerImpl() } single { TerminologyControllerImpl() } diff --git a/src/jvmMain/kotlin/controller/conversion/ConversionController.kt b/src/jvmMain/kotlin/controller/conversion/ConversionController.kt new file mode 100644 index 00000000..3c74e8cd --- /dev/null +++ b/src/jvmMain/kotlin/controller/conversion/ConversionController.kt @@ -0,0 +1,6 @@ +package controller.conversion + +interface ConversionController { + suspend fun convertRequest(content: String, type: String? = "json", version: String? = "5.0", toType: String? = type, + toVersion: String? = version): String +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/controller/conversion/ConversionControllerImpl.kt b/src/jvmMain/kotlin/controller/conversion/ConversionControllerImpl.kt new file mode 100644 index 00000000..4dee2e91 --- /dev/null +++ b/src/jvmMain/kotlin/controller/conversion/ConversionControllerImpl.kt @@ -0,0 +1,44 @@ +package controller.conversion + +import controller.validation.ValidationServiceFactory +import model.CliContext +import org.hl7.fhir.validation.ValidationEngine +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.deleteIfExists + +class ConversionControllerImpl : ConversionController, KoinComponent { + + private val validationServiceFactory by inject() + + override suspend fun convertRequest(content: String, type: String?, version: String?, toType: String?, + toVersion: String?): String { + val fromType = type ?: "json" + val fromVersion = version ?: "5.0" + + val cliContext = CliContext() + cliContext.sv = fromVersion + cliContext.targetVer = toVersion ?: fromVersion + + var validator: ValidationEngine? = validationServiceFactory.getValidationEngine(cliContext) + + var input: Path? = null + var output: Path? = null + try { + input = Files.createTempFile("input", ".$fromType") + Files.writeString(input.toAbsolutePath(), content) + cliContext.addSource(input.toAbsolutePath().toString()) + + output = Files.createTempFile("convert", ".${toType ?: fromType}") + cliContext.output = output.toAbsolutePath().toString() + + validationServiceFactory.getValidationService().convertSources(cliContext, validator) + return Files.readString(output.toAbsolutePath()) + } finally { + input?.deleteIfExists() + output?.deleteIfExists() + } + } +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/controller/conversion/ConversionModule.kt b/src/jvmMain/kotlin/controller/conversion/ConversionModule.kt new file mode 100644 index 00000000..63069f4f --- /dev/null +++ b/src/jvmMain/kotlin/controller/conversion/ConversionModule.kt @@ -0,0 +1,83 @@ +package controller.conversion + +import constants.CONVERSION_ENDPOINT + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.koin.ktor.ext.inject + +const val NO_CONTENT_PROVIDED_MESSAGE = "No content for conversion provided in request." +const val INVALID_TYPE_MESSAGE = "Invalid type parameter! Supported xml or json." +const val INVALID_TO_TYPE_MESSAGE = "Invalid toType parameter! Supported xml or json." + +fun Route.conversionModule() { + + val conversionController by inject() + + post(CONVERSION_ENDPOINT) { + val fhirJson = ContentType("application", "fhir+json") + val fhirXml = ContentType("application", "fhir+xml") + + val logger = call.application.environment.log + val content = call.receiveText() + val type = call.request.queryParameters["type"]?.lowercase() ?: when { + call.request.contentType() == ContentType.Application.Json -> "json" + call.request.contentType() == ContentType.Application.Xml -> "xml" + call.request.contentType().withoutParameters() == fhirJson -> "json" + call.request.contentType().withoutParameters() == fhirXml -> "xml" + else -> call.request.contentType().contentSubtype + } + val version = call.request.queryParameters["version"] ?: + call.request.contentType().parameter("fhirVersion") ?: "5.0" + + + val acceptContentType = if(call.request.accept() != null) + ContentType.parse(call.request.accept().toString()) else null + + val toType = call.request.queryParameters["toType"]?.lowercase() ?: when { + acceptContentType == ContentType.Application.Json -> "json" + acceptContentType == ContentType.Application.Xml -> "xml" + acceptContentType?.withoutParameters() == fhirJson -> "json" + acceptContentType?.withoutParameters() == fhirXml -> "xml" + call.request.accept() != null -> acceptContentType?.contentSubtype + else -> type + } + val toVersion = call.request.queryParameters["toVersion"] ?: + acceptContentType?.parameter("fhirVersion") ?: version + + logger.info("Received Conversion Request. Convert to $toVersion FHIR version and $toType type. " + + "Memory (free/max): ${java.lang.Runtime.getRuntime().freeMemory()}/" + + "${java.lang.Runtime.getRuntime().maxMemory()}") + + when { + content.isEmpty() -> { + call.respond(HttpStatusCode.BadRequest, NO_CONTENT_PROVIDED_MESSAGE) + } + type != "xml" && type != "json" -> { + call.respond(HttpStatusCode.BadRequest, INVALID_TYPE_MESSAGE) + } + toType != "xml" && toType != "json" -> { + call.respond(HttpStatusCode.BadRequest, INVALID_TO_TYPE_MESSAGE) + } + + else -> { + try { + val response = conversionController.convertRequest(content, type, version, toType, + toVersion) + val contentType = when { + toType == "xml" -> fhirXml.withParameter("fhirVersion", toVersion) + toType == "json" -> fhirJson.withParameter("fhirVersion", toVersion) + else -> acceptContentType?.withParameter("fhirVersion", toVersion) + } + call.respondText(response, contentType, HttpStatusCode.OK) + } catch (e: Exception) { + logger.error(e.localizedMessage, e) + call.respond(HttpStatusCode.InternalServerError, e.localizedMessage) + } + } + } + } +} diff --git a/src/jvmMain/kotlin/controller/validation/ValidationServiceFactory.kt b/src/jvmMain/kotlin/controller/validation/ValidationServiceFactory.kt index cc07a635..40627307 100644 --- a/src/jvmMain/kotlin/controller/validation/ValidationServiceFactory.kt +++ b/src/jvmMain/kotlin/controller/validation/ValidationServiceFactory.kt @@ -1,8 +1,12 @@ package controller.validation +import org.hl7.fhir.validation.ValidationEngine +import org.hl7.fhir.validation.cli.model.CliContext import org.hl7.fhir.validation.cli.services.ValidationService interface ValidationServiceFactory { fun getValidationService() : ValidationService + + fun getValidationEngine(cliContext: CliContext) : ValidationEngine? } \ No newline at end of file diff --git a/src/jvmMain/kotlin/controller/validation/ValidationServiceFactoryImpl.kt b/src/jvmMain/kotlin/controller/validation/ValidationServiceFactoryImpl.kt index 509ea0e6..daefd1a1 100644 --- a/src/jvmMain/kotlin/controller/validation/ValidationServiceFactoryImpl.kt +++ b/src/jvmMain/kotlin/controller/validation/ValidationServiceFactoryImpl.kt @@ -1,31 +1,51 @@ package controller.validation -import java.util.concurrent.TimeUnit; - -import org.hl7.fhir.validation.cli.services.ValidationService -import org.hl7.fhir.validation.cli.services.SessionCache +import org.hl7.fhir.utilities.TimeTracker +import org.hl7.fhir.utilities.VersionUtilities +import org.hl7.fhir.validation.ValidationEngine +import org.hl7.fhir.validation.cli.model.CliContext import org.hl7.fhir.validation.cli.services.PassiveExpiringSessionCache +import org.hl7.fhir.validation.cli.services.SessionCache +import org.hl7.fhir.validation.cli.services.ValidationService +import java.util.concurrent.TimeUnit private const val MIN_FREE_MEMORY = 250000000 private const val SESSION_DEFAULT_DURATION: Long = 60 class ValidationServiceFactoryImpl : ValidationServiceFactory { - private var validationService: ValidationService + @Volatile private var validationService: ValidationService = createValidationServiceInstance() + @Volatile private var sessionCache: SessionCache = createSessionCacheInstance() - init { - validationService = createValidationServiceInstance(); + private fun createSessionCacheInstance(): SessionCache { + val sessionCacheDuration = System.getenv("SESSION_CACHE_DURATION")?.toLong() ?: SESSION_DEFAULT_DURATION + return PassiveExpiringSessionCache(sessionCacheDuration, TimeUnit.MINUTES).setResetExpirationAfterFetch(true) } + private fun createValidationServiceInstance() : ValidationService { + sessionCache = createSessionCacheInstance() + return ValidationService(sessionCache) + } + + override fun getValidationEngine(cliContext: CliContext): ValidationEngine? { + var definitions = "hl7.fhir.r5.core#current" + if ("dev" != cliContext.sv) { + definitions = + VersionUtilities.packageForVersion(cliContext.sv) + "#" + + VersionUtilities.getCurrentVersion(cliContext.sv) + } + + var validationEngine = sessionCache.fetchSessionValidatorEngine(definitions) + if (validationEngine == null) { + validationEngine = getValidationService().initializeValidator(cliContext, definitions, TimeTracker()) + sessionCache.cacheSession(definitions, validationEngine) + } - fun createValidationServiceInstance() : ValidationService { - val sessionCacheDuration = System.getenv("SESSION_CACHE_DURATION")?.toLong() ?: SESSION_DEFAULT_DURATION; - val sessionCache: SessionCache = PassiveExpiringSessionCache(sessionCacheDuration, TimeUnit.MINUTES).setResetExpirationAfterFetch(true); - return ValidationService(sessionCache); + return validationEngine } override fun getValidationService() : ValidationService { if (java.lang.Runtime.getRuntime().freeMemory() < MIN_FREE_MEMORY) { println("Free memory ${java.lang.Runtime.getRuntime().freeMemory()} is less than ${MIN_FREE_MEMORY}. Re-initializing validationService"); - validationService = createValidationServiceInstance(); + validationService = createValidationServiceInstance() } return validationService; } diff --git a/src/jvmTest/kotlin/controller/conversion/ConversionControllerTest.kt b/src/jvmTest/kotlin/controller/conversion/ConversionControllerTest.kt new file mode 100644 index 00000000..0eb42cd5 --- /dev/null +++ b/src/jvmTest/kotlin/controller/conversion/ConversionControllerTest.kt @@ -0,0 +1,95 @@ +package controller.conversion + +import controller.BaseControllerTest +import controller.validation.ValidationServiceFactory +import instrumentation.ValidationInstrumentation.compareValidationResponses +import instrumentation.ValidationInstrumentation.givenAValidationRequest +import instrumentation.ValidationInstrumentation.givenAValidationResult +import instrumentation.ValidationInstrumentation.givenAnInternalValidatorError +import io.mockk.* +import kotlinx.coroutines.runBlocking +import org.hl7.fhir.validation.ValidationEngine +import org.hl7.fhir.validation.cli.model.CliContext +import org.hl7.fhir.validation.cli.services.ValidationService +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.koin.dsl.module +import java.io.File +import java.io.FileInputStream +import java.nio.file.Files +import java.nio.file.Path +import java.util.* +import kotlin.io.path.Path +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ConversionControllerTest : BaseControllerTest() { + private val validationService: ValidationService = mockk() + private val validationEngine: ValidationEngine = mockk() + private val validationServiceFactory : ValidationServiceFactory = mockk() + private val conversionController: ConversionController by lazy { ConversionControllerImpl() } + + private val conversionXml = "\n" + + "\n" + + " \n" + + "" + + private val conversionJson = "{\n" + + " \"resourceType\": \"Patient\",\n" + + " \"id\": \"1\"\n" + + "}" + + init { + startInjection( + module { + single() { validationServiceFactory } + } + ) + } + + @BeforeEach + override fun before() { + super.before() + clearMocks(validationServiceFactory) + clearMocks(validationService) + clearMocks(validationEngine) + every {validationServiceFactory.getValidationService() } returns validationService; + every {validationServiceFactory.getValidationEngine(any())} returns validationEngine; + } + + @Test + fun `test happy path`() { + val cliContextSlot = slot() + coEvery { + validationServiceFactory.getValidationService().convertSources(capture(cliContextSlot), any()) + } answers { + val cliContext = cliContextSlot.captured + assertTrue(cliContext.sources[0].endsWith(".json")) + assertEquals("5.0", cliContext.sv) + assertTrue(cliContext.output.endsWith(".xml")) + assertEquals("4.0", cliContext.targetVer) + val content = Files.readString(Path(cliContextSlot.captured.sources[0])) + assertEquals(conversionJson, content) + } + + runBlocking { + val result = conversionController.convertRequest(conversionJson, "json", "5.0", "xml", + "4.0") + assertEquals("", result) + } + } + + @Test + fun `test internal exception from ValidationService`() { + val internalError = Exception("Convert sources failed!") + coEvery { validationServiceFactory.getValidationService().convertSources(any(), any()) } throws internalError + val exception = Assertions.assertThrows(Exception::class.java) { + runBlocking { conversionController.convertRequest(conversionJson) } + } + Assertions.assertEquals(internalError.localizedMessage, exception.localizedMessage) + } + +} \ No newline at end of file diff --git a/src/jvmTest/kotlin/routing/conversion/ConversionRoutingTest.kt b/src/jvmTest/kotlin/routing/conversion/ConversionRoutingTest.kt new file mode 100644 index 00000000..78a5cdbe --- /dev/null +++ b/src/jvmTest/kotlin/routing/conversion/ConversionRoutingTest.kt @@ -0,0 +1,176 @@ +package routing.conversion + +import constants.CONVERSION_ENDPOINT +import controller.conversion.ConversionController +import controller.conversion.conversionModule + +import io.ktor.server.application.* +import io.ktor.http.* +import io.ktor.http.ContentType +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import io.mockk.coEvery +import io.mockk.mockk +import org.junit.jupiter.api.* +import org.koin.dsl.module +import routing.BaseRoutingTest +import java.nio.charset.Charset +import kotlin.test.assertEquals + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ConversionRoutingTest : BaseRoutingTest() { + + private val conversionController: ConversionController = mockk() + + private val conversionXml = "\n" + + "\n" + + " \n" + + "" + + private val conversionJson = "{\n" + + " \"resourceType\": \"Patient\",\n" + + " \"id\": \"1\"\n" + + "}" + + @BeforeAll + fun setup() { + koinModules = module { + single { conversionController } + } + + moduleList = { + install(Routing) { + conversionModule() + } + } + } + + @BeforeEach + fun clearMocks() { + io.mockk.clearMocks(conversionController) + } + + @Test + fun `when requesting conversion with a valid request, return conversion response body`() = withBaseTestApplication { + coEvery { conversionController.convertRequest(conversionXml, "xml", "4.0", "json", "4.0") } returns conversionJson + + val call = handleRequest(HttpMethod.Post, CONVERSION_ENDPOINT) { + addHeader(HttpHeaders.ContentType, "application/fhir+xml; fhirVersion=4.0") + addHeader(HttpHeaders.Accept, "application/fhir+json; fhirVersion=4.0") + setBody(conversionXml) + } + + with(call) { + assertEquals(HttpStatusCode.OK, response.status()) + assertEquals(ContentType("application", "fhir+json") + .withParameter("fhirVersion", "4.0").withCharset(Charset.defaultCharset()), + response.contentType().withCharset(Charset.defaultCharset())) + assertEquals(conversionJson, response.content) + } + } + + @Test + fun `when requesting conversion with params override headers`() = withBaseTestApplication { + coEvery { conversionController.convertRequest(conversionJson, "json", "5.0", "xml", "3.0") } returns conversionXml + + val call = handleRequest(HttpMethod.Post, CONVERSION_ENDPOINT + "?type=json&version=5.0&toType=xml&toVersion=3.0") { + addHeader(HttpHeaders.ContentType, "application/fhir+xml; fhirVersion=4.0") + addHeader(HttpHeaders.Accept, "application/fhir+json; fhirVersion=4.0") + setBody(conversionJson) + } + + with(call) { + assertEquals(HttpStatusCode.OK, response.status()) + assertEquals(ContentType("application", "fhir+xml") + .withParameter("fhirVersion", "3.0").withCharset(Charset.defaultCharset()), + response.contentType().withCharset(Charset.defaultCharset())) + assertEquals(conversionXml, response.content) + } + } + + @Test + fun `when requesting conversion with toType and toVersion params combine with header`() = withBaseTestApplication { + coEvery { conversionController.convertRequest(conversionJson, "json", "4.0", "xml", "3.0") } returns conversionXml + + val call = handleRequest(HttpMethod.Post, CONVERSION_ENDPOINT + "?toType=xml&toVersion=3.0") { + addHeader(HttpHeaders.ContentType, "application/fhir+json; fhirVersion=4.0") + setBody(conversionJson) + } + + with(call) { + assertEquals(HttpStatusCode.OK, response.status()) + assertEquals(ContentType("application", "fhir+xml") + .withParameter("fhirVersion", "3.0").withCharset(Charset.defaultCharset()), + response.contentType().withCharset(Charset.defaultCharset())) + assertEquals(conversionXml, response.content) + } + } + + @Test + fun `when requesting conversion with toType param combine with header`() = withBaseTestApplication { + coEvery { conversionController.convertRequest(conversionJson, "json", "4.0", "xml", "4.0") } returns conversionXml + + val call = handleRequest(HttpMethod.Post, CONVERSION_ENDPOINT + "?toType=xml") { + addHeader(HttpHeaders.ContentType, "application/fhir+json; fhirVersion=4.0") + setBody(conversionJson) + } + + with(call) { + assertEquals(HttpStatusCode.OK, response.status()) + assertEquals(ContentType("application", "fhir+xml") + .withParameter("fhirVersion", "4.0").withCharset(Charset.defaultCharset()), + response.contentType().withCharset(Charset.defaultCharset())) + assertEquals(conversionXml, response.content) + } + } + + @Test + fun `when requesting conversion with toType and without custom headers use defaults`() = withBaseTestApplication { + coEvery { conversionController.convertRequest(conversionJson, "json", "5.0", "xml", "5.0") } returns conversionXml + + val call = handleRequest(HttpMethod.Post, CONVERSION_ENDPOINT + "?toType=xml") { + addHeader(HttpHeaders.ContentType, "application/json") + setBody(conversionJson) + } + + with(call) { + assertEquals(HttpStatusCode.OK, response.status()) + assertEquals(ContentType("application", "fhir+xml") + .withParameter("fhirVersion", "5.0").withCharset(Charset.defaultCharset()), + response.contentType().withCharset(Charset.defaultCharset())) + assertEquals(conversionXml, response.content) + } + } + + @Test + fun `test internal exception from conversion service results in internal server error`() = withBaseTestApplication { + val internalError = Exception("Conversion error!") + coEvery { conversionController.convertRequest(allAny()) } throws internalError + + val call = handleRequest(HttpMethod.Post, CONVERSION_ENDPOINT) { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(conversionXml) + } + + with(call) { + assertEquals(HttpStatusCode.InternalServerError, response.status()) + assertEquals(quoteWrap(internalError.localizedMessage), response.content) + } + } + + @Test + fun `test sending a request containing no body results in bad request returned`() = withBaseTestApplication { + val internalError = Exception("Conversion error!") + coEvery { conversionController.convertRequest(allAny()) } throws internalError + + val call = handleRequest(HttpMethod.Post, CONVERSION_ENDPOINT) { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + // No body + } + + with(call) { + assertEquals(HttpStatusCode.BadRequest, response.status()) + assertEquals(quoteWrap("No content for conversion provided in request."), response.content) + } + } +} \ No newline at end of file