diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 00000000..471d690f --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1 @@ +* Added gradle task to provide release version to pipelines diff --git a/build.gradle.kts b/build.gradle.kts index 72b9a58e..349ee183 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,11 +3,12 @@ import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpack plugins { kotlin("multiplatform") version "1.4.0" kotlin("plugin.serialization") version "1.4.0" + id("org.hidetake.ssh") version "2.10.1" id ("org.openjfx.javafxplugin") version "0.0.8" application } group = "org.hl7.fhir" -version = "1.0-SNAPSHOT" +version = "0.0.1" repositories { google() @@ -133,7 +134,7 @@ kotlin { } } javafx { - version = "14"//"11.0.2" + version = "14" modules("javafx.controls", "javafx.graphics", "javafx.web") } application { @@ -154,20 +155,30 @@ tasks.getByName("run") { dependsOn(tasks.getByName("jvmJar")) classpath(tasks.getByName("jvmJar")) } -tasks.withType { - manifest { - attributes["Main-Class"] = "ServerKt" - } - - // To add all of the dependencies otherwise a "NoClassDefFoundError" error - from(sourceSets.main.get().output) - - dependsOn(configurations.runtimeClasspath) - from({ - configurations.runtimeClasspath.get().filter { it.name.endsWith("jar") }.map { zipTree(it) } - }) -} +// +//tasks.withType { +// manifest { +// attributes["Main-Class"] = "ServerKt" +// } +// +// // To add all of the dependencies otherwise a "NoClassDefFoundError" error +// from(sourceSets.main.get().output) +// +// dependsOn(configurations.runtimeClasspath) +// from({ +// configurations.runtimeClasspath.get().filter { it.name.endsWith("jar") }.map { zipTree(it) } +// }) +//} tasks.withType().configureEach { options.compilerArgs = listOf("-Xmx2g", "-XX:MaxMetaspaceSize=512m") +} + +/** + * Utility function to retrieve the current version number. + */ +task("printVersion") { + doLast { + println(project.version) + } } \ No newline at end of file diff --git a/src/commonMain/kotlin/constants/MIMETypes.kt b/src/commonMain/kotlin/constants/MIMETypes.kt index 10862fd1..1bb789ac 100644 --- a/src/commonMain/kotlin/constants/MIMETypes.kt +++ b/src/commonMain/kotlin/constants/MIMETypes.kt @@ -1,8 +1,8 @@ package constants enum class MIMEType(val code: String, val image: String, val fhirType: String) { - JSON("text/xml", "images/xml_icon.svg", "xml"), - XML("application/json", "images/json_icon.svg", "json"); + JSON("text/xml", "static/images/xml_icon.svg", "xml"), + XML("application/json", "static/images/json_icon.svg", "json"); companion object { // Reverse-lookup map for getting a day from an abbreviation diff --git a/src/jsMain/kotlin/ui/components/ContextSettingsComponent.kt b/src/jsMain/kotlin/ui/components/ContextSettingsComponent.kt index 03a8995c..4004b004 100644 --- a/src/jsMain/kotlin/ui/components/ContextSettingsComponent.kt +++ b/src/jsMain/kotlin/ui/components/ContextSettingsComponent.kt @@ -9,7 +9,6 @@ import css.text.TextStyle import css.widget.CheckboxStyle import kotlinx.coroutines.launch import kotlinx.css.* -import kotlinx.html.id import kotlinx.html.js.onClickFunction import mainScope import model.CliContext diff --git a/src/jsMain/kotlin/ui/components/FileUpload.kt b/src/jsMain/kotlin/ui/components/FileUpload.kt index 940a0e3b..9bc50122 100644 --- a/src/jsMain/kotlin/ui/components/FileUpload.kt +++ b/src/jsMain/kotlin/ui/components/FileUpload.kt @@ -3,9 +3,7 @@ package ui.components import css.component.FileUploadStyle import css.widget.FABStyle import kotlinx.browser.document -import kotlinx.coroutines.launch import kotlinx.html.js.onClickFunction -import mainScope import org.w3c.dom.HTMLInputElement import react.* import reactredux.containers.uploadFilesButton diff --git a/src/jsMain/kotlin/ui/components/generic/OptionEntryField.kt b/src/jsMain/kotlin/ui/components/generic/OptionEntryField.kt index a359b236..ad2c9ec1 100644 --- a/src/jsMain/kotlin/ui/components/generic/OptionEntryField.kt +++ b/src/jsMain/kotlin/ui/components/generic/OptionEntryField.kt @@ -1,19 +1,11 @@ package ui.components.generic import css.component.OptionEntryFieldStyle -import css.const.GRAY_100 -import css.const.ICON_SMALL_DIM -import css.const.SHADOW -import css.text.TextStyle -import css.widget.Spinner import kotlinx.browser.document -import kotlinx.css.* -import kotlinx.css.properties.boxShadow import kotlinx.html.id import kotlinx.html.js.onClickFunction import org.w3c.dom.HTMLTextAreaElement import react.* -import react.dom.textArea import styled.* external interface OptionEntryFieldProps : RProps { diff --git a/src/jvmMain/kotlin/HtmlIndex.kt b/src/jvmMain/kotlin/HtmlIndex.kt new file mode 100644 index 00000000..daaa8653 --- /dev/null +++ b/src/jvmMain/kotlin/HtmlIndex.kt @@ -0,0 +1,28 @@ +import kotlinx.html.* + +/** + * We use kotlinx to create this HTML index file which contains the reference to `/static/output.js`, the location + * of our generated JS code from KotlinJS. This is also where we load in our external fonts and set the main 'root' div. + */ +fun HTML.index() { + head { + meta { + charset = "UTF-8" + } + title("Validator GUI") + link( + href = "https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap", + rel = "stylesheet" + ) + link( + href = "https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,900;1,200;1,300;1,400;1,500;1,600;1,700;1,900&display=swap", + rel = "stylesheet" + ) + } + body { + div { + id = "root" + } + script(src = "/static/output.js") {} + } +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/Module.kt b/src/jvmMain/kotlin/Module.kt new file mode 100644 index 00000000..3ddc15de --- /dev/null +++ b/src/jvmMain/kotlin/Module.kt @@ -0,0 +1,100 @@ +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.SerializationFeature +import desktop.launchLocalApp +import io.ktor.application.* +import io.ktor.features.* +import io.ktor.html.* +import io.ktor.http.* +import io.ktor.http.content.* +import io.ktor.jackson.* +import io.ktor.routing.* +import kotlinx.html.* +import org.slf4j.event.Level +import routes.debugRoutes +import routes.igRoutes +import routes.validationRoutes +import routes.versionRoutes + +/** + * Entry point of the application. This function is referenced in the resources/application.conf file (see the line + * that says `modules = [ ModuleKt.module ]`, pointing to this method. + */ +fun Application.module() { + // Any DB initialization will go here. + val starting: (Application) -> Unit = { log.info("Application starting: $it") } + val started: (Application) -> Unit = { + log.info("Application started: $it") + if (runningAsDesktopStandalone) { + launchLocalApp() + } + } + val stopping: (Application) -> Unit = { log.info("Application stopping: $it") } + var stopped: (Application) -> Unit = {} + + stopped = { + log.info("Application stopped: $it") + environment.monitor.unsubscribe(ApplicationStarting, starting) + environment.monitor.unsubscribe(ApplicationStarted, started) + environment.monitor.unsubscribe(ApplicationStopping, stopping) + environment.monitor.unsubscribe(ApplicationStopped, stopped) + } + + environment.monitor.subscribe(ApplicationStarted, starting) + environment.monitor.subscribe(ApplicationStarted, started) + environment.monitor.subscribe(ApplicationStopping, stopping) + environment.monitor.subscribe(ApplicationStopped, stopped) + + // Now we call to a main with the dependencies as arguments. + // Separating this function with its dependencies allows us to provide several modules with + // the same code and different data-sources living in the same application, + // and to provide mocked instances for doing integration tests. + start() +} + +/** + * Application extension function where we configure Ktor application with features, interceptors and routing. + */ +fun Application.start() { + + install(CallLogging) { + level = Level.DEBUG + } + + install(CORS) { + method(HttpMethod.Get) + method(HttpMethod.Post) + method(HttpMethod.Delete) + anyHost() + } + + install(Compression) { + gzip() + } + + install(ContentNegotiation) { + jackson { + enable(SerializationFeature.INDENT_OUTPUT) + /* + * Right now we need to ignore unknown fields because we take a very simplified version of many of the fhir + * model classes, and map them to classes across JVM/Common/JS. + */ + configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + } + } + + install(Routing) { + get("/") { + call.respondHtml(HttpStatusCode.OK, HTML::index) + } + + static("/static") { + resources() + } + resources() + validationRoutes() + versionRoutes() + igRoutes() + // Only enable if things have gone horribly, and you need to add a debug logging endpoint. + //debugRoutes() + } +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/Server.kt b/src/jvmMain/kotlin/Server.kt index 0e8fd90f..6acded6a 100644 --- a/src/jvmMain/kotlin/Server.kt +++ b/src/jvmMain/kotlin/Server.kt @@ -1,154 +1,101 @@ -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.SerializationFeature -import desktop.launchLocalApp -import io.ktor.application.* -import io.ktor.features.* -import io.ktor.html.* -import io.ktor.http.* -import io.ktor.http.content.* -import io.ktor.jackson.* -import io.ktor.routing.* +import com.typesafe.config.ConfigFactory +import io.ktor.config.* import io.ktor.server.engine.* import io.ktor.server.jetty.* -import kotlinx.html.* -import org.hl7.fhir.utilities.VersionUtilities -import org.hl7.fhir.validation.ValidationEngine +import io.ktor.util.* import org.hl7.fhir.validation.ValidatorCli -import org.hl7.fhir.validation.cli.model.CliContext -import org.hl7.fhir.validation.cli.utils.Common import org.hl7.fhir.validation.cli.utils.Params -import routes.* import java.util.concurrent.TimeUnit -lateinit var cliContext: CliContext +private const val DEFAULT_ENVIRONMENT: String = "dev" +private const val FULL_STACK_FLAG = "-startServer" +private const val LOCAL_APP_FLAG = "-gui" + lateinit var engine: JettyApplicationEngine var runningAsDesktopStandalone: Boolean = false +/** + * Here we define Java app main method. There are three possible ways this jar can be utilized: + * + * **1. As a full-stack hosted server:** + * + * Execute the jar by providing the argument `'-startServer'`. This boots the Ktor validation back end and KotlinJS + * front end. Refer to the application.conf file in the resources directory to view the different deployment flavours + * available. These deployment types can be set through the environment variable "ENVIRONMENT". If no such environment + * variable is set, the application will default to a 'dev' type deployment. + * + * **2. As a locally run, short-lived, 'desktop' application:** + * + * Execute the jar by providing the argument `'-gui'`. This boots the Ktor server locally on the port 8080, and starts a + * wrapped instance of the KotlinJS front end within a Chromium web window to appear as a desktop application. This + * wrapped website should mimic all the same functionality of the full KotlinJS website as described in the first + * option. Once the Chromium browser window is closed, the local Ktor server is stopped. + * + * **3. As the traditional validator clr:** + * + * Users can still execute this jar as done previously, from the command line and access all validator cli + * functionality as detailed here: `https://confluence.hl7.org/display/FHIR/Using+the+FHIR+Validator` + * + * **N.B.** + * If you attempt to run this as both a full-stack server, and a locally hosted application, the full-stack server + * takes priority, and the desktop version will not be booted. + */ fun main(args: Array) { - if (args.isNotEmpty() && !Params.hasParam(args, "-gui")) { - ValidatorCli.main(args) - } else { - runningAsDesktopStandalone = Params.hasParam(args, "-gui") - startServer(args) + when { + runningAsCli(args) -> { + ValidatorCli.main(args) + } + else -> { + runningAsDesktopStandalone = runningAsDesktopApp(args) + startServer(args) + } } } fun startServer(args: Array) { - val applicationEnvironment = commandLineEnvironment(args) - engine = JettyApplicationEngine(applicationEnvironment) { - loadCommonConfiguration(applicationEnvironment.config) - } - engine.start(true) + val environment = System.getenv()["ENVIRONMENT"] ?: handleDefaultEnvironment() + val config = extractConfig(environment, HoconApplicationConfig(ConfigFactory.load())) + + embeddedServer(Jetty,host = config.host, port = config.port) { + println("Starting instance in ${config.host}:${config.port}") + module() + }.start(wait = true) } +/** + * Force shutdown the Ktor server. + */ fun stopServer() { engine.stop(0, 5, TimeUnit.SECONDS) } -fun Application.module() { - init(CliContext()) +private fun runningAsCli(args: Array): Boolean { + return args.isNotEmpty() && !Params.hasParam(args, LOCAL_APP_FLAG) && !Params.hasParam(args, FULL_STACK_FLAG) } -/** - * Entry Point of the application. This function is referenced in the resources/application.conf file. - */ -fun Application.init(context: CliContext) { - // Any DB initialization will go here. - - // Any event subscriptions or other setup will go here. - cliContext = context - - val starting: (Application) -> Unit = { log.info("Application starting: $it") } - val started: (Application) -> Unit = { - log.info("Application started: $it") - if (runningAsDesktopStandalone) { - launchLocalApp() - } - } - val stopping: (Application) -> Unit = { log.info("Application stopping: $it") } - var stopped: (Application) -> Unit = {} - - stopped = { - log.info("Application stopped: $it") - environment.monitor.unsubscribe(ApplicationStarting, starting) - environment.monitor.unsubscribe(ApplicationStarted, started) - environment.monitor.unsubscribe(ApplicationStopping, stopping) - environment.monitor.unsubscribe(ApplicationStopped, stopped) - } - - environment.monitor.subscribe(ApplicationStarted, starting) - environment.monitor.subscribe(ApplicationStarted, started) - environment.monitor.subscribe(ApplicationStopping, stopping) - environment.monitor.subscribe(ApplicationStopped, stopped) - - // Now we call to a main with the dependencies as arguments. - // Separating this function with its dependencies allows us to provide several modules with - // the same code and different data-sources living in the same application, - // and to provide mocked instances for doing integration tests. - start() +private fun runningAsDesktopApp(args: Array): Boolean { + return args.isNotEmpty() && Params.hasParam(args, LOCAL_APP_FLAG) && !Params.hasParam(args, FULL_STACK_FLAG) } -fun Application.start() { - - install(CallLogging) - - install(CORS) { - method(HttpMethod.Get) - method(HttpMethod.Post) - method(HttpMethod.Delete) - anyHost() - } - - install(Compression) { - gzip() - } - - install(ContentNegotiation) { - jackson { - enable(SerializationFeature.INDENT_OUTPUT) - /* - * Right now we need to ignore unknown fields because we take a very simplified version of many of the fhir - * model classes, and map them to classes across JVM/Common/JS. - */ - configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - } - } - - routing { - get("/") { - call.respondHtml(HttpStatusCode.OK, HTML::index) - } - static("/static") { - resources() - } - resources() - validationRoutes() - versionRoutes() - igRoutes() - debugRoutes() - } +data class Config( + val host: String, + val port: Int, +) + +@KtorExperimentalAPI +fun extractConfig(environment: String, hoconConfig: HoconApplicationConfig): Config { + val hoconEnvironment = hoconConfig.config("ktor.deployment.$environment") + return Config( + hoconEnvironment.property("host").getString(), + Integer.parseInt(hoconEnvironment.property("port").getString()), + ) } -fun HTML.index() { - head { - meta { - charset = "UTF-8" - } - title("Validator GUI") - link( - href = "https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap", - rel = "stylesheet" - ) - link( - href = "https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,900;1,200;1,300;1,400;1,500;1,600;1,700;1,900&display=swap", - rel = "stylesheet" - ) - } - body { - div { - id = "root" - } - script(src = "/static/output.js") {} - } +/** + * Returns default environment. + */ +fun handleDefaultEnvironment(): String { + println("Falling back to default environment 'dev'") + return DEFAULT_ENVIRONMENT } \ No newline at end of file diff --git a/src/jvmMain/kotlin/desktop/ApplicationView.kt b/src/jvmMain/kotlin/desktop/ApplicationView.kt index 8e2828ca..f1f3d283 100644 --- a/src/jvmMain/kotlin/desktop/ApplicationView.kt +++ b/src/jvmMain/kotlin/desktop/ApplicationView.kt @@ -14,10 +14,18 @@ private const val PREF_WIDTH = 1200.0 private const val PREF_HEIGHT = 1000.0 /** - * To display the webview natively as an application on desktops, we use the TornadoFX library (https://tornadofx.io/). - * As a result, our desktop.CliApp mus extend the App class in TornadoFx. + * Instead of maintaining code for three separate desktop applications (OSX, Linux, Windows), we take the existing + * KotlinJS front end, and use the TornadoFX library `(https://tornadofx.io/)` to wrap it in a Chromium powered webview. + * This chromium app then communicates with a locally hosted instance of the Ktor backend, giving the 'illusion' of a + * local desktop application. + * + * To use TornadoFX, our desktop.CliApp must extend the App class in TornadoFx. */ class CliApp: App(ApplicationView::class) { + /** + * On close, we need to shutdown the Ktor backend server as well. We do this by overriding the stop method, then + * calling the `stopServer()` we defined. + */ override fun stop() { super.stop() stopServer() @@ -33,6 +41,7 @@ class ApplicationView: View() { init { with(root) { setPrefSize(PREF_WIDTH, PREF_HEIGHT) + // TODO fix this so it's not hardcoded engine.load("http://localhost:8080/") } } @@ -45,6 +54,4 @@ fun launchLocalApp() { GlobalScope.launch { tornadofx.launch() } -} - -//TODO shut down server on window close \ No newline at end of file +} \ No newline at end of file diff --git a/src/jvmMain/resources/application.conf b/src/jvmMain/resources/application.conf index 68b72439..5183e912 100644 --- a/src/jvmMain/resources/application.conf +++ b/src/jvmMain/resources/application.conf @@ -1,9 +1,15 @@ ktor { - deployment { - port = 8080 - port = ${?PORT} - } - application { - modules = [ ServerKt.module ] - } + deployment { + dev { + host = "localhost" + port = 8080 + } + prod { + host = "test.com" + port = 3500 + } + } + application { + modules = [ ModuleKt.module ] + } } \ No newline at end of file diff --git a/src/jvmTest/kotlin/routes/RoutingTestUtils.kt b/src/jvmTest/kotlin/routes/RoutingTestUtils.kt index 5c02ee85..a5200439 100644 --- a/src/jvmTest/kotlin/routes/RoutingTestUtils.kt +++ b/src/jvmTest/kotlin/routes/RoutingTestUtils.kt @@ -1,15 +1,11 @@ package routes -import init import io.ktor.server.testing.* -import org.hl7.fhir.validation.cli.model.CliContext +import module /** * Private method used to reduce boilerplate when testing the application. */ -fun testWithApp( - cliContext: CliContext = CliContext(), - callback: TestApplicationEngine.() -> Unit, -) { - withTestApplication({ init(context = cliContext) }) { callback() } +fun testWithApp(callback: TestApplicationEngine.() -> Unit, ) { + withTestApplication({ module() }) { callback() } } \ No newline at end of file