Refactor server API

This commit is contained in:
Alexander Nozik 2022-12-03 13:53:34 +03:00
parent 20b20a621f
commit c8141c6338
No known key found for this signature in database
GPG Key ID: F7FCF2DD25C71357
5 changed files with 240 additions and 191 deletions

View File

@ -2,6 +2,7 @@ package space.kscience.visionforge.examples
import io.ktor.server.cio.CIO import io.ktor.server.cio.CIO
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.http.content.resources
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
import kotlinx.html.* import kotlinx.html.*
import space.kscience.dataforge.context.Global import space.kscience.dataforge.context.Global
@ -10,6 +11,7 @@ import space.kscience.visionforge.VisionManager
import space.kscience.visionforge.html.VisionPage import space.kscience.visionforge.html.VisionPage
import space.kscience.visionforge.html.formFragment import space.kscience.visionforge.html.formFragment
import space.kscience.visionforge.onPropertyChange import space.kscience.visionforge.onPropertyChange
import space.kscience.visionforge.server.EngineConnectorConfig
import space.kscience.visionforge.server.close import space.kscience.visionforge.server.close
import space.kscience.visionforge.server.openInBrowser import space.kscience.visionforge.server.openInBrowser
import space.kscience.visionforge.server.visionPage import space.kscience.visionforge.server.visionPage
@ -17,48 +19,54 @@ import space.kscience.visionforge.server.visionPage
fun main() { fun main() {
val visionManager = Global.fetch(VisionManager) val visionManager = Global.fetch(VisionManager)
val server = embeddedServer(CIO, 7777, "localhost") {
val connector = EngineConnectorConfig("localhost", 7777)
val server = embeddedServer(CIO, connector.port, connector.host) {
routing { routing {
visionPage(visionManager, VisionPage.scriptHeader("js/visionforge-playground.js")) { resources()
val form = formFragment("form") { }
label {
htmlFor = "fname"
+"First name:"
}
br()
input {
type = InputType.text
id = "fname"
name = "fname"
value = "John"
}
br()
label {
htmlFor = "lname"
+"Last name:"
}
br()
input {
type = InputType.text
id = "lname"
name = "lname"
value = "Doe"
}
br()
br()
input {
type = InputType.submit
value = "Submit"
}
}
vision("form") { form } visionPage(connector, visionManager, VisionPage.scriptHeader("js/visionforge-playground.js")) {
form.onPropertyChange { val form = formFragment("form") {
println(this) label {
htmlFor = "fname"
+"First name:"
}
br()
input {
type = InputType.text
id = "fname"
name = "fname"
value = "John"
}
br()
label {
htmlFor = "lname"
+"Last name:"
}
br()
input {
type = InputType.text
id = "lname"
name = "lname"
value = "Doe"
}
br()
br()
input {
type = InputType.submit
value = "Submit"
} }
} }
vision("form") { form }
form.onPropertyChange {
println(this)
}
} }
}.start(false) }.start(false)
server.openInBrowser() server.openInBrowser()

View File

@ -15,6 +15,7 @@ import space.kscience.dataforge.meta.Null
import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.Name
import space.kscience.visionforge.Colors import space.kscience.visionforge.Colors
import space.kscience.visionforge.html.VisionPage import space.kscience.visionforge.html.VisionPage
import space.kscience.visionforge.server.EngineConnectorConfig
import space.kscience.visionforge.server.close import space.kscience.visionforge.server.close
import space.kscience.visionforge.server.openInBrowser import space.kscience.visionforge.server.openInBrowser
import space.kscience.visionforge.server.visionPage import space.kscience.visionforge.server.visionPage
@ -36,21 +37,26 @@ fun main() {
color.set(Colors.white) color.set(Colors.white)
} }
} }
val connector = EngineConnectorConfig("localhost", 7777)
val server = embeddedServer(CIO,7777, "localhost") { val server = embeddedServer(CIO, connector.port, connector.host) {
routing { routing {
static { static {
resources() resources()
} }
}
visionPage(solids.visionManager, VisionPage.threeJsHeader, VisionPage.styleSheetHeader("css/styles.css")) { visionPage(
div("flex-column") { connector,
h1 { +"Satellite detector demo" } solids.visionManager, VisionPage.threeJsHeader,
vision { sat } VisionPage.styleSheetHeader("css/styles.css")
} ) {
div("flex-column") {
h1 { +"Satellite detector demo" }
vision { sat }
} }
} }
}.start(false) }.start(false)
server.openInBrowser() server.openInBrowser()

View File

@ -4,9 +4,8 @@ import io.ktor.http.URLProtocol
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.cio.CIO import io.ktor.server.cio.CIO
import io.ktor.server.engine.ApplicationEngine import io.ktor.server.engine.ApplicationEngine
import io.ktor.server.engine.EngineConnectorConfig
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.Routing
import io.ktor.server.routing.route
import io.ktor.server.util.url import io.ktor.server.util.url
import io.ktor.server.websocket.WebSockets import io.ktor.server.websocket.WebSockets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -26,8 +25,8 @@ import space.kscience.visionforge.html.HtmlFormFragment
import space.kscience.visionforge.html.HtmlVisionFragment import space.kscience.visionforge.html.HtmlVisionFragment
import space.kscience.visionforge.html.VisionCollector import space.kscience.visionforge.html.VisionCollector
import space.kscience.visionforge.html.visionFragment import space.kscience.visionforge.html.visionFragment
import space.kscience.visionforge.server.VisionRouteConfiguration import space.kscience.visionforge.server.EngineConnectorConfig
import space.kscience.visionforge.server.require import space.kscience.visionforge.server.VisionRoute
import space.kscience.visionforge.server.serveVisionData import space.kscience.visionforge.server.serveVisionData
import space.kscience.visionforge.visionManager import space.kscience.visionforge.visionManager
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@ -48,8 +47,6 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout
public val visionManager: VisionManager = context.visionManager public val visionManager: VisionManager = context.visionManager
private val configuration = VisionRouteConfiguration(visionManager)
private var counter = 0 private var counter = 0
private var engine: ApplicationEngine? = null private var engine: ApplicationEngine? = null
@ -68,7 +65,7 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout
public fun startServer( public fun startServer(
host: String = context.properties["visionforge.host"].string ?: "localhost", host: String = context.properties["visionforge.host"].string ?: "localhost",
port: Int = context.properties["visionforge.port"].int ?: VisionRouteConfiguration.DEFAULT_PORT, port: Int = context.properties["visionforge.port"].int ?: VisionRoute.DEFAULT_PORT,
): MimeTypedResult = html { ): MimeTypedResult = html {
if (engine != null) { if (engine != null) {
p { p {
@ -77,6 +74,8 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout
} }
} }
val connector: EngineConnectorConfig = EngineConnectorConfig(host, port)
engine?.stop(1000, 2000) engine?.stop(1000, 2000)
engine = context.embeddedServer(CIO, port, host) { engine = context.embeddedServer(CIO, port, host) {
install(WebSockets) install(WebSockets)
@ -111,7 +110,7 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout
val collector: VisionCollector = mutableMapOf() val collector: VisionCollector = mutableMapOf()
val url = engine.environment.connectors.first().let { val url = engine.environment.connectors.first().let {
url{ url {
protocol = URLProtocol.WS protocol = URLProtocol.WS
host = it.host host = it.host
port = it.port port = it.port
@ -119,6 +118,8 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout
} }
} }
engine.application.serveVisionData(VisionRoute(cellRoute, visionManager), collector)
visionFragment( visionFragment(
context, context,
embedData = true, embedData = true,
@ -126,13 +127,6 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout
collector = collector, collector = collector,
fragment = fragment fragment = fragment
) )
engine.application.require(Routing) {
route(cellRoute) {
serveVisionData(TODO(), collector)
}
}
} else { } else {
//if not, use static rendering //if not, use static rendering
visionFragment(context, fragment = fragment) visionFragment(context, fragment = fragment)

View File

@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.html.* import kotlinx.html.*
import kotlinx.html.stream.createHTML
import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.ContextAware import space.kscience.dataforge.context.ContextAware
import space.kscience.dataforge.meta.* import space.kscience.dataforge.meta.*
@ -30,33 +31,32 @@ import space.kscience.visionforge.VisionChange
import space.kscience.visionforge.VisionManager import space.kscience.visionforge.VisionManager
import space.kscience.visionforge.flowChanges import space.kscience.visionforge.flowChanges
import space.kscience.visionforge.html.* import space.kscience.visionforge.html.*
import java.awt.Desktop
import java.net.URI
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
public enum class DataServeMode { public class VisionRoute(
/** public val route: String,
* Embed the initial state of the vision inside its html tag.
*/
EMBED,
/**
* Fetch data on vision load. Do not embed data.
*/
FETCH,
/**
* Connect to server to get pushes. The address of the server is embedded in the tag.
*/
UPDATE
}
public class VisionRouteConfiguration(
public val visionManager: VisionManager, public val visionManager: VisionManager,
override val meta: ObservableMutableMeta = MutableMeta(), override val meta: ObservableMutableMeta = MutableMeta(),
) : Configurable, ContextAware { ) : Configurable, ContextAware {
public enum class Mode {
/**
* Embed the initial state of the vision inside its html tag.
*/
EMBED,
/**
* Fetch data on vision load. Do not embed data.
*/
FETCH,
/**
* Connect to server to get pushes. The address of the server is embedded in the tag.
*/
UPDATE
}
override val context: Context get() = visionManager.context override val context: Context get() = visionManager.context
/** /**
@ -64,7 +64,7 @@ public class VisionRouteConfiguration(
*/ */
public var updateInterval: Long by meta.long(300, key = UPDATE_INTERVAL_KEY) public var updateInterval: Long by meta.long(300, key = UPDATE_INTERVAL_KEY)
public var dataMode: DataServeMode by meta.enum(DataServeMode.UPDATE) public var dataMode: Mode by meta.enum(Mode.UPDATE)
public companion object { public companion object {
public const val DEFAULT_PORT: Int = 7777 public const val DEFAULT_PORT: Int = 7777
@ -78,65 +78,74 @@ public class VisionRouteConfiguration(
* Serve visions in a given [route] without providing a page template. * Serve visions in a given [route] without providing a page template.
* [visions] could be changed during the service. * [visions] could be changed during the service.
*/ */
public fun Route.serveVisionData( public fun Application.serveVisionData(
configuration: VisionRouteConfiguration, configuration: VisionRoute,
resolveVision: (Name) -> Vision?, resolveVision: (Name) -> Vision?,
) { ) {
application.log.info("Serving visions at ${this@serveVisionData}") require(WebSockets)
routing {
//Update websocket route(configuration.route) {
webSocket("ws") { install(CORS) {
val name: String = call.request.queryParameters.getOrFail("name") anyHost()
application.log.debug("Opened server socket for $name")
val vision: Vision = resolveVision(Name.parse(name)) ?: error("Plot with id='$name' not registered")
launch {
incoming.consumeEach {
val data = it.data.decodeToString()
application.log.debug("Received update: \n$data")
val change = configuration.visionManager.jsonFormat.decodeFromString(
VisionChange.serializer(), data
)
vision.update(change)
} }
} application.log.info("Serving visions at ${configuration.route}")
try { //Update websocket
withContext(configuration.context.coroutineContext) { webSocket("ws") {
vision.flowChanges(configuration.updateInterval.milliseconds).onEach { update -> val name: String = call.request.queryParameters.getOrFail("name")
val json = configuration.visionManager.jsonFormat.encodeToString( application.log.debug("Opened server socket for $name")
VisionChange.serializer(), val vision: Vision = resolveVision(Name.parse(name)) ?: error("Plot with id='$name' not registered")
update
launch {
incoming.consumeEach {
val data = it.data.decodeToString()
application.log.debug("Received update: \n$data")
val change = configuration.visionManager.jsonFormat.decodeFromString(
VisionChange.serializer(), data
)
vision.update(change)
}
}
try {
withContext(configuration.context.coroutineContext) {
vision.flowChanges(configuration.updateInterval.milliseconds).onEach { update ->
val json = configuration.visionManager.jsonFormat.encodeToString(
VisionChange.serializer(),
update
)
application.log.debug("Sending update: \n$json")
outgoing.send(Frame.Text(json))
}.collect()
}
} catch (t: Throwable) {
this.application.log.info("WebSocket update channel for $name is closed with exception: $t")
}
}
//Plots in their json representation
get("data") {
val name: String = call.request.queryParameters.getOrFail("name")
val vision: Vision? = resolveVision(Name.parse(name))
if (vision == null) {
call.respond(HttpStatusCode.NotFound, "Vision with name '$name' not found")
} else {
call.respondText(
configuration.visionManager.encodeToString(vision),
contentType = ContentType.Application.Json,
status = HttpStatusCode.OK
) )
application.log.debug("Sending update: \n$json") }
outgoing.send(Frame.Text(json))
}.collect()
} }
} catch (t: Throwable) {
this.application.log.info("WebSocket update channel for $name is closed with exception: $t")
}
}
//Plots in their json representation
get("data") {
val name: String = call.request.queryParameters.getOrFail("name")
val vision: Vision? = resolveVision(Name.parse(name))
if (vision == null) {
call.respond(HttpStatusCode.NotFound, "Vision with name '$name' not found")
} else {
call.respondText(
configuration.visionManager.encodeToString(vision),
contentType = ContentType.Application.Json,
status = HttpStatusCode.OK
)
} }
} }
} }
public fun Route.serveVisionData( public fun Application.serveVisionData(
configuration: VisionRouteConfiguration, configuration: VisionRoute,
cache: VisionCollector, cache: VisionCollector,
): Unit = serveVisionData(configuration) { cache[it]?.second } ): Unit = serveVisionData(configuration) { cache[it]?.second }
// //
///** ///**
// * Compile a fragment to string and serve visions from it // * Compile a fragment to string and serve visions from it
@ -161,91 +170,85 @@ public fun Route.serveVisionData(
/** /**
* Serve a page, potentially containing any number of visions at a given [route] with given [header]. * Serve a page, potentially containing any number of visions at a given [route] with given [header].
*/ */
public fun Route.visionPage( public fun Application.visionPage(
configuration: VisionRouteConfiguration, route: String,
configuration: VisionRoute,
connector: EngineConnectorConfig,
headers: Collection<HtmlFragment>, headers: Collection<HtmlFragment>,
visionFragment: HtmlVisionFragment, visionFragment: HtmlVisionFragment,
) { ) {
application.require(WebSockets) require(WebSockets)
require(CORS) {
anyHost()
}
val visionCache: VisionCollector = mutableMapOf() val collector: VisionCollector = mutableMapOf()
serveVisionData(configuration, visionCache)
//filled pages val html = createHTML().apply {
get { head {
//re-create html and vision list on each call meta {
call.respondHtml { charset = "utf-8"
val callbackUrl = call.url()
head {
meta {
charset = "utf-8"
}
headers.forEach { header ->
consumer.header()
}
} }
body { headers.forEach { header ->
//Load the fragment and remember all loaded visions consumer.header()
visionFragment(
context = configuration.context,
embedData = configuration.dataMode == DataServeMode.EMBED,
fetchDataUrl = if (configuration.dataMode != DataServeMode.EMBED) {
URLBuilder(callbackUrl).apply {
pathSegments = pathSegments + "data"
}.buildString()
} else null,
updatesUrl = if (configuration.dataMode == DataServeMode.UPDATE) {
URLBuilder(callbackUrl).apply {
protocol = URLProtocol.WS
pathSegments = pathSegments + "ws"
}.buildString()
} else null,
visionCache = visionCache,
fragment = visionFragment
)
} }
} }
body {
//Load the fragment and remember all loaded visions
visionFragment(
context = configuration.context,
embedData = configuration.dataMode == VisionRoute.Mode.EMBED,
fetchDataUrl = if (configuration.dataMode != VisionRoute.Mode.EMBED) {
url {
host = connector.host
port = connector.port
path(route, "data")
}
} else null,
updatesUrl = if (configuration.dataMode == VisionRoute.Mode.UPDATE) {
url {
protocol = URLProtocol.WS
host = connector.host
port = connector.port
path(route, "ws")
}
} else null,
visionCache = collector,
fragment = visionFragment
)
}
}.finalize()
//serve data
serveVisionData(configuration, collector)
//filled pages
routing {
get(route) {
call.respondText(html, ContentType.Text.Html)
}
} }
} }
public fun Route.visionPage( public fun Application.visionPage(
connector: EngineConnectorConfig,
visionManager: VisionManager, visionManager: VisionManager,
vararg headers: HtmlFragment, vararg headers: HtmlFragment,
configurationBuilder: VisionRouteConfiguration.() -> Unit = {}, route: String = "/",
configurationBuilder: VisionRoute.() -> Unit = {},
visionFragment: HtmlVisionFragment, visionFragment: HtmlVisionFragment,
) { ) {
val configuration = VisionRouteConfiguration(visionManager).apply(configurationBuilder) val configuration = VisionRoute(route, visionManager).apply(configurationBuilder)
visionPage(configuration, listOf(*headers), visionFragment) visionPage(route, configuration, connector, listOf(*headers), visionFragment)
} }
/** /**
* Render given [VisionPage] at server * Render given [VisionPage] at server
*/ */
public fun Route.visionPage(page: VisionPage, configurationBuilder: VisionRouteConfiguration.() -> Unit = {}) { public fun Application.visionPage(
val configuration = VisionRouteConfiguration(page.visionManager).apply(configurationBuilder) connector: EngineConnectorConfig,
visionPage(configuration, page.pageHeaders.values, visionFragment = page.content) page: VisionPage,
route: String = "/",
configurationBuilder: VisionRoute.() -> Unit = {},
) {
val configuration = VisionRoute(route, page.visionManager).apply(configurationBuilder)
visionPage(route, configuration, connector, page.pageHeaders.values, visionFragment = page.content)
} }
public fun <P : Pipeline<*, ApplicationCall>, B : Any, F : Any> P.require(
plugin: Plugin<P, B, F>,
configure: B.() -> Unit = {},
): F = pluginOrNull(plugin) ?: install(plugin, configure)
/**
* Connect to a given Ktor server using browser
*/
public fun ApplicationEngine.openInBrowser() {
val connector = environment.connectors.first()
val uri = URI("http", null, connector.host, connector.port, null, null, null)
Desktop.getDesktop().browse(uri)
}
/**
* Stop the server with default timeouts
*/
public fun ApplicationEngine.close(): Unit = stop(1000, 5000)

View File

@ -0,0 +1,38 @@
package space.kscience.visionforge.server
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.Plugin
import io.ktor.server.application.install
import io.ktor.server.application.pluginOrNull
import io.ktor.server.engine.ApplicationEngine
import io.ktor.server.engine.EngineConnectorBuilder
import io.ktor.server.engine.EngineConnectorConfig
import io.ktor.util.pipeline.Pipeline
import java.awt.Desktop
import java.net.URI
public fun <P : Pipeline<*, ApplicationCall>, B : Any, F : Any> P.require(
plugin: Plugin<P, B, F>,
): F = pluginOrNull(plugin) ?: install(plugin)
/**
* Connect to a given Ktor server using browser
*/
public fun ApplicationEngine.openInBrowser() {
val connector = environment.connectors.first()
val uri = URI("http", null, connector.host, connector.port, null, null, null)
Desktop.getDesktop().browse(uri)
}
/**
* Stop the server with default timeouts
*/
public fun ApplicationEngine.close(): Unit = stop(1000, 5000)
public fun EngineConnectorConfig(host: String, port: Int): EngineConnectorConfig = EngineConnectorBuilder().apply {
this.host = host
this.port = port
}