Skip to content

Commit

Permalink
Implement /convert endpoint for xml/json conversion (hapifhir#165)
Browse files Browse the repository at this point in the history
  • Loading branch information
rkorytkowski committed Apr 29, 2024
1 parent ddb9920 commit a373fbc
Show file tree
Hide file tree
Showing 12 changed files with 520 additions and 12 deletions.
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}")
Expand Down
1 change: 1 addition & 0 deletions src/commonMain/kotlin/constants/Endpoints.kt
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
70 changes: 70 additions & 0 deletions src/commonMain/resources/static-content/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions src/jvmMain/kotlin/Module.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import controller.conversion.conversionModule
import controller.debug.debugModule
import controller.ig.igModule
import controller.terminology.terminologyModule
Expand All @@ -15,6 +17,8 @@ import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.http.*
import io.ktor.server.http.content.*
import io.ktor.serialization.jackson.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.serialization.kotlinx.xml.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

Expand Down Expand Up @@ -98,6 +102,7 @@ fun Application.setup() {
versionModule()
debugModule()
validationModule()
conversionModule()
terminologyModule()
uptimeModule()

Expand Down
3 changes: 3 additions & 0 deletions src/jvmMain/kotlin/controller/ControllersInjection.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,6 +17,7 @@ import org.koin.dsl.module
object ControllersInjection {
val koinBeans = module {
single<ValidationController> { ValidationControllerImpl() }
single<ConversionController> { ConversionControllerImpl() }
single<VersionController> { VersionControllerImpl() }
single<IgController> { IgControllerImpl() }
single<TerminologyController> { TerminologyControllerImpl() }
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<ValidationServiceFactory>()

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()
}
}
}
83 changes: 83 additions & 0 deletions src/jvmMain/kotlin/controller/conversion/ConversionModule.kt
Original file line number Diff line number Diff line change
@@ -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<ConversionController>()

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)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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?
}
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
Loading

0 comments on commit a373fbc

Please sign in to comment.