diff options
author | Robert Jaros <rjaros@finn.pl> | 2020-03-11 16:18:17 +0100 |
---|---|---|
committer | Robert Jaros <rjaros@finn.pl> | 2020-03-11 16:18:17 +0100 |
commit | 9e243a469ae6544e8cf523ad09b959f541c3f565 (patch) | |
tree | be9c7447e221af49180c9e98434df7f988b940b8 /kvision-modules/kvision-common-remote | |
parent | ec6084c42c13a621e17b17bd40d90b5c7879f0ec (diff) | |
download | kvision-9e243a469ae6544e8cf523ad09b959f541c3f565.tar.gz kvision-9e243a469ae6544e8cf523ad09b959f541c3f565.tar.bz2 kvision-9e243a469ae6544e8cf523ad09b959f541c3f565.zip |
Upgrade to Kotlin 1.3.70 + other dependencies (Coroutinse, Serialization, Spring Boot)
Major refactoring of build architecture.
Diffstat (limited to 'kvision-modules/kvision-common-remote')
13 files changed, 1471 insertions, 146 deletions
diff --git a/kvision-modules/kvision-common-remote/build.gradle b/kvision-modules/kvision-common-remote/build.gradle deleted file mode 100644 index af3703e6..00000000 --- a/kvision-modules/kvision-common-remote/build.gradle +++ /dev/null @@ -1,10 +0,0 @@ -apply plugin: 'kotlin-platform-common' -apply plugin: 'kotlinx-serialization' - -dependencies { - compile "org.jetbrains.kotlin:kotlin-stdlib-common:$kotlinVersion" - compile "org.jetbrains.kotlinx:kotlinx-serialization-runtime-common:$serializationVersion" - compile "org.jetbrains.kotlinx:kotlinx-coroutines-core-common:$coroutinesVersion" - testCompile "org.jetbrains.kotlin:kotlin-test-common:$kotlinVersion" - testCompile "org.jetbrains.kotlin:kotlin-test-annotations-common:$kotlinVersion" -} diff --git a/kvision-modules/kvision-common-remote/build.gradle.kts b/kvision-modules/kvision-common-remote/build.gradle.kts new file mode 100644 index 00000000..a7daa88e --- /dev/null +++ b/kvision-modules/kvision-common-remote/build.gradle.kts @@ -0,0 +1,57 @@ +buildscript { + extra.set("production", (findProperty("prod") ?: findProperty("production") ?: "false") == "true") +} + +plugins { + kotlin("multiplatform") + id("kotlinx-serialization") + id("maven-publish") +} + +repositories() + +// Versions +val serializationVersion: String by project +val coroutinesVersion: String by project +val jacksonModuleKotlinVersion: String by project +val jqueryKotlinVersion: String by project + +kotlin { + kotlinJsTargets() + kotlinJvmTargets() + sourceSets { + val commonMain by getting { + dependencies { + implementation(kotlin("stdlib-common")) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-runtime-common:$serializationVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-common:$coroutinesVersion") + } + } + val jsMain by getting { + dependencies { + implementation(kotlin("stdlib-js")) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-runtime-js:$serializationVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-js:$coroutinesVersion") + implementation("pl.treksoft:jquery-kotlin:$jqueryKotlinVersion") + } + } + val jvmMain by getting { + dependencies { + implementation(kotlin("stdlib")) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-runtime:$serializationVersion") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonModuleKotlinVersion") + } + } + } +} + +publishing { + publications.withType<MavenPublication> { + if (name == "kotlinMultiplatform") artifactId = "kvision-common-remote" + pom { + defaultPom() + } + } +} + +setupPublication() diff --git a/kvision-modules/kvision-common-remote/src/main/kotlin/pl/treksoft/kvision/remote/JsonRpc.kt b/kvision-modules/kvision-common-remote/src/commonMain/kotlin/pl/treksoft/kvision/remote/JsonRpc.kt index d82189a0..d82189a0 100644 --- a/kvision-modules/kvision-common-remote/src/main/kotlin/pl/treksoft/kvision/remote/JsonRpc.kt +++ b/kvision-modules/kvision-common-remote/src/commonMain/kotlin/pl/treksoft/kvision/remote/JsonRpc.kt diff --git a/kvision-modules/kvision-common-remote/src/main/kotlin/pl/treksoft/kvision/remote/Annotations.kt b/kvision-modules/kvision-common-remote/src/commonMain/kotlin/pl/treksoft/kvision/remote/KVServiceMgr.kt index 3fb82d2e..2d68f1d0 100644 --- a/kvision-modules/kvision-common-remote/src/main/kotlin/pl/treksoft/kvision/remote/Annotations.kt +++ b/kvision-modules/kvision-common-remote/src/commonMain/kotlin/pl/treksoft/kvision/remote/KVServiceMgr.kt @@ -19,12 +19,10 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package pl.treksoft.kvision.remote -@UseExperimental(ExperimentalMultiplatform::class) -@OptionalExpectation -expect annotation class Id() +package pl.treksoft.kvision.remote -@UseExperimental(ExperimentalMultiplatform::class) -@OptionalExpectation -expect annotation class Transient() +@Suppress("unused") +interface KVServiceMgr<T : Any> { + fun getCalls(): Map<String, Pair<String, HttpMethod>> +} diff --git a/kvision-modules/kvision-common-remote/src/main/kotlin/pl/treksoft/kvision/remote/RemoteTypes.kt b/kvision-modules/kvision-common-remote/src/commonMain/kotlin/pl/treksoft/kvision/remote/RemoteTypes.kt index 62b2354b..d96aa30c 100644 --- a/kvision-modules/kvision-common-remote/src/main/kotlin/pl/treksoft/kvision/remote/RemoteTypes.kt +++ b/kvision-modules/kvision-common-remote/src/commonMain/kotlin/pl/treksoft/kvision/remote/RemoteTypes.kt @@ -23,6 +23,14 @@ package pl.treksoft.kvision.remote import kotlinx.serialization.Serializable +enum class HttpMethod { + GET, + POST, + PUT, + DELETE, + OPTIONS +} + class ServiceException(message: String) : Exception(message) @Serializable diff --git a/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/CallAgent.kt b/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/CallAgent.kt new file mode 100644 index 00000000..6c1e629c --- /dev/null +++ b/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/CallAgent.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2017-present Robert Jaros + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package pl.treksoft.kvision.remote + +import kotlinx.serialization.ImplicitReflectionSerializer +import kotlinx.serialization.stringify +import org.w3c.dom.get +import pl.treksoft.jquery.JQueryAjaxSettings +import pl.treksoft.jquery.JQueryXHR +import pl.treksoft.jquery.jQuery +import kotlin.browser.window +import kotlin.js.Promise +import kotlin.js.JSON as NativeJSON + +/** + * HTTP status unauthorized (401). + */ +const val HTTP_UNAUTHORIZED = 401 + +/** + * An agent responsible for remote calls. + */ +open class CallAgent { + + private val kvUrlPrefix = window["kv_remote_url_prefix"] + private val urlPrefix: String = if (kvUrlPrefix != undefined) kvUrlPrefix else "" + private var counter = 1 + + /** + * Makes an JSON-RPC call to the remote server. + * @param url an URL address + * @param data data to be sent + * @param method a HTTP method + * @return a promise of the result + */ + @OptIn(ImplicitReflectionSerializer::class) + @Suppress("UnsafeCastFromDynamic", "ComplexMethod") + fun jsonRpcCall( + url: String, + data: List<String?> = listOf(), + method: HttpMethod = HttpMethod.POST + ): Promise<String> { + val jsonRpcRequest = JsonRpcRequest(counter++, url, data) + val jsonData = if (method == HttpMethod.GET) { + obj { id = jsonRpcRequest.id } + } else { + JSON.plain.stringify(jsonRpcRequest) + } + return Promise { resolve, reject -> + jQuery.ajax(urlPrefix + url, obj { + this.contentType = "application/json" + this.data = jsonData + this.method = method.name + this.success = + { data: dynamic, _: Any, _: Any -> + when { + data.id != jsonRpcRequest.id -> reject(Exception("Invalid response ID")) + data.error != null -> { + if (data.exceptionType == "pl.treksoft.kvision.remote.ServiceException") { + reject(ServiceException(data.error.toString())) + } else { + reject(Exception(data.error.toString())) + } + } + data.result != null -> resolve(data.result) + else -> reject(Exception("Invalid response")) + } + } + this.error = + { xhr: JQueryXHR, _: String, errorText: String -> + val message = if (xhr.responseJSON != null && xhr.responseJSON != undefined) { + NativeJSON.stringify(xhr.responseJSON) + } else if (xhr.responseText != undefined) { + xhr.responseText + } else { + errorText + } + if (xhr.status.toInt() == HTTP_UNAUTHORIZED) { + reject(SecurityException(message)) + } else { + reject(Exception(message)) + } + } + this.xhrFields = obj { + this.withCredentials = true + } + }) + } + } + + /** + * Makes a remote call to the remote server. + * @param url an URL address + * @param data data to be sent + * @param method a HTTP method + * @param contentType a content type of the request + * @param beforeSend a function to set request parameters + * @return a promise of the result + */ + @Suppress("UnsafeCastFromDynamic", "ComplexMethod") + fun remoteCall( + url: String, + data: dynamic = null, + method: HttpMethod = HttpMethod.GET, + contentType: String = "application/json", + beforeSend: ((JQueryXHR, JQueryAjaxSettings) -> Boolean)? = null + ): Promise<dynamic> { + return Promise { resolve, reject -> + jQuery.ajax(urlPrefix + url, obj { + this.contentType = if (contentType != "multipart/form-data") contentType else false + this.data = data + this.method = method.name + this.processData = if (contentType != "multipart/form-data") undefined else false + this.success = + { data: dynamic, _: Any, _: Any -> + resolve(data) + } + this.error = + { xhr: JQueryXHR, _: String, errorText: String -> + val message = if (xhr.responseJSON != null && xhr.responseJSON != undefined) { + NativeJSON.stringify(xhr.responseJSON) + } else if (xhr.responseText != undefined) { + xhr.responseText + } else { + errorText + } + if (xhr.status.toInt() == HTTP_UNAUTHORIZED) { + reject(SecurityException(message)) + } else { + reject(Exception(message)) + } + } + this.beforeSend = beforeSend + this.xhrFields = obj { + this.withCredentials = true + } + }) + } + } +} diff --git a/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/KVRemoteAgent.kt b/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/KVRemoteAgent.kt new file mode 100644 index 00000000..62657b3d --- /dev/null +++ b/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/KVRemoteAgent.kt @@ -0,0 +1,577 @@ +/* + * Copyright (c) 2017-present Robert Jaros + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package pl.treksoft.kvision.remote + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.asDeferred +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.serialization.ImplicitReflectionSerializer +import kotlinx.serialization.builtins.list +import kotlinx.serialization.serializer +import kotlinx.serialization.stringify +import kotlin.reflect.KClass + +/** + * Client side agent for JSON-RPC remote calls. + */ +@Suppress("LargeClass", "TooManyFunctions") +@OptIn( + ImplicitReflectionSerializer::class, ExperimentalCoroutinesApi::class +) +open class KVRemoteAgent<T : Any>(val serviceManager: KVServiceMgr<T>) : + RemoteAgent { + + val callAgent = CallAgent() + + /** + * Executes defined call to a remote web service. + */ + suspend inline fun <reified RET : Any, T> call(noinline function: suspend T.() -> RET): RET { + val (url, method) = + serviceManager.getCalls()[function.toString().replace("\\s".toRegex(), "")] + ?: throw IllegalStateException("Function not specified!") + return callAgent.jsonRpcCall(url, method = method).then { + try { + @Suppress("UNCHECKED_CAST") + deserialize<RET>(it, RET::class.js.name) + } catch (t: NotStandardTypeException) { + try { + @Suppress("UNCHECKED_CAST") + tryDeserializeEnum(RET::class as KClass<Any>, it) as RET + } catch (t: NotEnumTypeException) { + JSON.nonstrict.parse(RET::class.serializer(), it) + } + } + }.asDeferred().await() + } + + /** + * Executes defined call to a remote web service. + */ + suspend inline fun <reified RET : Any, T> call( + noinline function: suspend T.() -> List<RET> + ): List<RET> { + val (url, method) = + serviceManager.getCalls()[function.toString().replace("\\s".toRegex(), "")] + ?: throw IllegalStateException("Function not specified!") + return callAgent.jsonRpcCall(url, method = method).then { + try { + deserializeList<RET>(it, RET::class.js.name) + } catch (t: NotStandardTypeException) { + try { + @Suppress("UNCHECKED_CAST") + tryDeserializeEnumList(RET::class as KClass<Any>, it) as List<RET> + } catch (t: NotEnumTypeException) { + JSON.nonstrict.parse(RET::class.serializer().list, it) + } + } + }.asDeferred().await() + } + + /** + * Executes defined call to a remote web service. + */ + suspend inline fun <reified PAR, reified RET : Any, T> call( + noinline function: suspend T.(PAR) -> RET, p: PAR + ): RET { + val data = serialize(p) + val (url, method) = + serviceManager.getCalls()[function.toString().replace("\\s".toRegex(), "")] + ?: throw IllegalStateException("Function not specified!") + return callAgent.jsonRpcCall(url, listOf(data), method).then { + try { + @Suppress("UNCHECKED_CAST") + deserialize<RET>(it, RET::class.js.name) + } catch (t: NotStandardTypeException) { + try { + @Suppress("UNCHECKED_CAST") + tryDeserializeEnum(RET::class as KClass<Any>, it) as RET + } catch (t: NotEnumTypeException) { + JSON.nonstrict.parse(RET::class.serializer(), it) + } + } + }.asDeferred().await() + } + + /** + * Executes defined call to a remote web service. + */ + suspend inline fun <reified PAR, reified RET : Any, T> call( + noinline function: suspend T.(PAR) -> List<RET>, p: PAR + ): List<RET> { + val data = serialize(p) + val (url, method) = + serviceManager.getCalls()[function.toString().replace("\\s".toRegex(), "")] + ?: throw IllegalStateException("Function not specified!") + return callAgent.jsonRpcCall(url, listOf(data), method).then { + try { + deserializeList<RET>(it, RET::class.js.name) + } catch (t: NotStandardTypeException) { + try { + @Suppress("UNCHECKED_CAST") + tryDeserializeEnumList(RET::class as KClass<Any>, it) as List<RET> + } catch (t: NotEnumTypeException) { + JSON.nonstrict.parse(RET::class.serializer().list, it) + } + } + }.asDeferred().await() + } + + /** + * Executes defined call to a remote web service. + */ + suspend inline fun <reified PAR1, reified PAR2, reified RET : Any, T> call( + noinline function: suspend T.(PAR1, PAR2) -> RET, p1: PAR1, p2: PAR2 + ): RET { + val data1 = serialize(p1) + val data2 = serialize(p2) + val (url, method) = + serviceManager.getCalls()[function.toString().replace("\\s".toRegex(), "")] + ?: throw IllegalStateException("Function not specified!") + return callAgent.jsonRpcCall(url, listOf(data1, data2), method).then { + try { + @Suppress("UNCHECKED_CAST") + deserialize<RET>(it, RET::class.js.name) + } catch (t: NotStandardTypeException) { + try { + @Suppress("UNCHECKED_CAST") + tryDeserializeEnum(RET::class as KClass<Any>, it) as RET + } catch (t: NotEnumTypeException) { + JSON.nonstrict.parse(RET::class.serializer(), it) + } + } + }.asDeferred().await() + } + + /** + * Executes defined call to a remote web service. + */ + suspend inline fun <reified PAR1, reified PAR2, reified RET : Any, T> call( + noinline function: suspend T.(PAR1, PAR2) -> List<RET>, p1: PAR1, p2: PAR2 + ): List<RET> { + val data1 = serialize(p1) + val data2 = serialize(p2) + val (url, method) = + serviceManager.getCalls()[function.toString().replace("\\s".toRegex(), "")] + ?: throw IllegalStateException("Function not specified!") + return callAgent.jsonRpcCall(url, listOf(data1, data2), method).then { + try { + deserializeList<RET>(it, RET::class.js.name) + } catch (t: NotStandardTypeException) { + try { + @Suppress("UNCHECKED_CAST") + tryDeserializeEnumList(RET::class as KClass<Any>, it) as List<RET> + } catch (t: NotEnumTypeException) { + JSON.nonstrict.parse(RET::class.serializer().list, it) + } + } + }.asDeferred().await() + } + + /** + * Executes defined call to a remote web service. + */ + suspend inline fun <reified PAR1, reified PAR2, reified PAR3, reified RET : Any, T> call( + noinline function: suspend T.(PAR1, PAR2, PAR3) -> RET, p1: PAR1, p2: PAR2, p3: PAR3 + ): RET { + val data1 = serialize(p1) + val data2 = serialize(p2) + val data3 = serialize(p3) + val (url, method) = + serviceManager.getCalls()[function.toString().replace("\\s".toRegex(), "")] + ?: throw IllegalStateException("Function not specified!") + return callAgent.jsonRpcCall(url, listOf(data1, data2, data3), method).then { + try { + @Suppress("UNCHECKED_CAST") + deserialize<RET>(it, RET::class.js.name) + } catch (t: NotStandardTypeException) { + try { + @Suppress("UNCHECKED_CAST") + tryDeserializeEnum(RET::class as KClass<Any>, it) as RET + } catch (t: NotEnumTypeException) { + JSON.nonstrict.parse(RET::class.serializer(), it) + } + } + }.asDeferred().await() + } + + /** + * Executes defined call to a remote web service. + */ + suspend inline fun <reified PAR1, reified PAR2, reified PAR3, reified RET : Any, T> call( + noinline function: suspend T.(PAR1, PAR2, PAR3) -> List<RET>, p1: PAR1, p2: PAR2, p3: PAR3 + ): List<RET> { + val data1 = serialize(p1) + val data2 = serialize(p2) + val data3 = serialize(p3) + val (url, method) = + serviceManager.getCalls()[function.toString().replace("\\s".toRegex(), "")] + ?: throw IllegalStateException("Function not specified!") + return callAgent.jsonRpcCall(url, listOf(data1, data2, data3), method).then { + try { + deserializeList<RET>(it, RET::class.js.name) + } catch (t: NotStandardTypeException) { + try { + @Suppress("UNCHECKED_CAST") + tryDeserializeEnumList(RET::class as KClass<Any>, it) as List<RET> + } catch (t: NotEnumTypeException) { + JSON.nonstrict.parse(RET::class.serializer().list, it) + } + } + }.asDeferred().await() + } + + /** + * Executes defined call to a remote web service. + */ + suspend inline fun <reified PAR1, reified PAR2, reified PAR3, reified PAR4, reified RET : Any, T> call( + noinline function: suspend T.(PAR1, PAR2, PAR3, PAR4) -> RET, p1: PAR1, p2: PAR2, p3: PAR3, p4: PAR4 + ): RET { + val data1 = serialize(p1) + val data2 = serialize(p2) + val data3 = serialize(p3) + val data4 = serialize(p4) + val (url, method) = + serviceManager.getCalls()[function.toString().replace("\\s".toRegex(), "")] + ?: throw IllegalStateException("Function not specified!") + return callAgent.jsonRpcCall(url, listOf(data1, data2, data3, data4), method).then { + try { + @Suppress("UNCHECKED_CAST") + deserialize<RET>(it, RET::class.js.name) + } catch (t: NotStandardTypeException) { + try { + @Suppress("UNCHECKED_CAST") + tryDeserializeEnum(RET::class as KClass<Any>, it) as RET + } catch (t: NotEnumTypeException) { + JSON.nonstrict.parse(RET::class.serializer(), it) + } + } + }.asDeferred().await() + } + + /** + * Executes defined call to a remote web service. + */ + suspend inline fun <reified PAR1, reified PAR2, reified PAR3, reified PAR4, reified RET : Any, T> call( + noinline function: suspend T.(PAR1, PAR2, PAR3, PAR4) -> List<RET>, + p1: PAR1, + p2: PAR2, + p3: PAR3, + p4: PAR4 + ): List<RET> { + val data1 = serialize(p1) + val data2 = serialize(p2) + val data3 = serialize(p3) + val data4 = serialize(p4) + val (url, method) = + serviceManager.getCalls()[function.toString().replace("\\s".toRegex(), "")] + ?: throw IllegalStateException("Function not specified!") + return callAgent.jsonRpcCall(url, listOf(data1, data2, data3, data4), method).then { + try { + deserializeList<RET>(it, RET::class.js.name) + } catch (t: NotStandardTypeException) { + try { + @Suppress("UNCHECKED_CAST") + tryDeserializeEnumList(RET::class as KClass<Any>, it) as List<RET> + } catch (t: NotEnumTypeException) { + JSON.nonstrict.parse(RET::class.serializer().list, it) + } + } + }.asDeferred().await() + } + + /** + * Executes defined call to a remote web service. + */ + @Suppress("LongParameterList") + suspend inline fun <reified PAR1, reified PAR2, reified PAR3, reified PAR4, reified PAR5, + reified RET : Any, T> call( + noinline function: suspend T.(PAR1, PAR2, PAR3, PAR4, PAR5) -> RET, + p1: PAR1, + p2: PAR2, + p3: PAR3, + p4: PAR4, + p5: PAR5 + ): RET { + val data1 = serialize(p1) + val data2 = serialize(p2) + val data3 = serialize(p3) + val data4 = serialize(p4) + val data5 = serialize(p5) + val (url, method) = + serviceManager.getCalls()[function.toString().replace("\\s".toRegex(), "")] + ?: throw IllegalStateException("Function not specified!") + return callAgent.jsonRpcCall(url, listOf(data1, data2, data3, data4, data5), method).then { + try { + @Suppress("UNCHECKED_CAST") + deserialize<RET>(it, RET::class.js.name) + } catch (t: NotStandardTypeException) { + try { + @Suppress("UNCHECKED_CAST") + tryDeserializeEnum(RET::class as KClass<Any>, it) as RET + } catch (t: NotEnumTypeException) { + JSON.nonstrict.parse(RET::class.serializer(), it) + } + } + }.asDeferred().await() + } + + /** + * Executes defined call to a remote web service. + */ + @Suppress("LongParameterList") + suspend inline fun <reified PAR1, reified PAR2, reified PAR3, reified PAR4, reified PAR5, + reified RET : Any, T> call( + noinline function: suspend T.(PAR1, PAR2, PAR3, PAR4, PAR5) -> List<RET>, + p1: PAR1, + p2: PAR2, + p3: PAR3, + p4: PAR4, + p5: PAR5 + ): List<RET> { + val data1 = serialize(p1) + val data2 = serialize(p2) + val data3 = serialize(p3) + val data4 = serialize(p4) + val data5 = serialize(p5) + val (url, method) = + serviceManager.getCalls()[function.toString().replace("\\s".toRegex(), "")] + ?: throw IllegalStateException("Function not specified!") + return callAgent.jsonRpcCall(url, listOf(data1, data2, data3, data4, data5), method).then { + try { + deserializeList<RET>(it, RET::class.js.name) + } catch (t: NotStandardTypeException) { + try { + @Suppress("UNCHECKED_CAST") + tryDeserializeEnumList(RET::class as KClass<Any>, it) as List<RET> + } catch (t: NotEnumTypeException) { + JSON.nonstrict.parse(RET::class.serializer().list, it) + } + } + }.asDeferred().await() + } + + /** + * Executes defined web socket connection + */ + @Suppress("ComplexMethod", "TooGenericExceptionCaught") + suspend inline fun <reified PAR1 : Any, reified PAR2 : Any> webSocket( + noinline function: suspend T.(ReceiveChannel<PAR1>, SendChannel<PAR2>) -> Unit, + noinline handler: suspend (SendChannel<PAR1>, ReceiveChannel<PAR2>) -> Unit + ) { + val (url, _) = + serviceManager.getCalls()[function.toString().replace("\\s".toRegex(), "")] + ?: throw IllegalStateException("Function not specified!") + val socket = Socket() + val requestChannel = Channel<PAR1>() + val responseChannel = Channel<PAR2>() + try { + coroutineScope { + socket.connect(getWebSocketUrl(url)) + lateinit var responseJob: Job + lateinit var handlerJob: Job + val requestJob = launch { + for (par1 in requestChannel) { + val param = serialize(par1) + val str = JSON.plain.stringify( + JsonRpcRequest( + 0, + url, + listOf(param) + ) + ) + if (!socket.sendOrFalse(str)) break + } + responseJob.cancel() + handlerJob.cancel() + if (!requestChannel.isClosedForReceive) requestChannel.close() + if (!responseChannel.isClosedForSend) responseChannel.close() + } + responseJob = launch { + while (true) { + val str = socket.receiveOrNull() ?: break + val data = kotlin.js.JSON.parse<JsonRpcResponse>(str).result ?: "" + val par2 = try { + @Suppress("UNCHECKED_CAST") + deserialize<PAR2>(data, PAR2::class.js.name) + } catch (t: NotStandardTypeException) { + try { + @Suppress("UNCHECKED_CAST") + tryDeserializeEnum(PAR2::class as KClass<Any>, data) as PAR2 + } catch (t: NotEnumTypeException) { + JSON.nonstrict.parse(PAR2::class.serializer(), data) + } + } + responseChannel.send(par2) + } + requestJob.cancel() + handlerJob.cancel() + if (!requestChannel.isClosedForReceive) requestChannel.close() + if (!responseChannel.isClosedForSend) responseChannel.close() + } + handlerJob = launch { + exceptionHelper { + handler(requestChannel, responseChannel) + } + requestJob.cancel() + responseJob.cancel() + if (!requestChannel.isClosedForReceive) requestChannel.close() + if (!responseChannel.isClosedForSend) responseChannel.close() + } + } + } catch (e: Exception) { + console.log(e) + } + if (!requestChannel.isClosedForReceive) requestChannel.close() + if (!responseChannel.isClosedForSend) responseChannel.close() + socket.close() + } + + /** + * Executes defined web socket connection returning list objects + */ + @Suppress("ComplexMethod", "TooGenericExceptionCaught") + suspend inline fun <reified PAR1 : Any, reified PAR2 : Any> webSocket( + noinline function: suspend T.(ReceiveChannel<PAR1>, SendChannel<List<PAR2>>) -> Unit, + noinline handler: suspend (SendChannel<PAR1>, ReceiveChannel<List<PAR2>>) -> Unit + ) { + val (url, _) = + serviceManager.getCalls()[function.toString().replace("\\s".toRegex(), "")] + ?: throw IllegalStateException("Function not specified!") + val socket = Socket() + val requestChannel = Channel<PAR1>() + val responseChannel = Channel<List<PAR2>>() + try { + coroutineScope { + socket.connect(getWebSocketUrl(url)) + lateinit var responseJob: Job + lateinit var handlerJob: Job + val requestJob = launch { + for (par1 in requestChannel) { + val param = serialize(par1) + val str = JSON.plain.stringify( + JsonRpcRequest( + 0, + url, + listOf(param) + ) + ) + if (!socket.sendOrFalse(str)) break + } + responseJob.cancel() + handlerJob.cancel() + if (!requestChannel.isClosedForReceive) requestChannel.close() + if (!responseChannel.isClosedForSend) responseChannel.close() + } + responseJob = launch { + while (true) { + val str = socket.receiveOrNull() ?: break + val data = kotlin.js.JSON.parse<JsonRpcResponse>(str).result ?: "" + val par2 = try { + deserializeList<PAR2>(data, PAR2::class.js.name) + } catch (t: NotStandardTypeException) { + try { + @Suppress("UNCHECKED_CAST") + tryDeserializeEnumList(PAR2::class as KClass<Any>, data) as List<PAR2> + } catch (t: NotEnumTypeException) { + JSON.nonstrict.parse(PAR2::class.serializer().list, data) + } + } + responseChannel.send(par2) + } + requestJob.cancel() + handlerJob.cancel() + if (!requestChannel.isClosedForReceive) requestChannel.close() + if (!responseChannel.isClosedForSend) responseChannel.close() + } + handlerJob = launch { + exceptionHelper { + handler(requestChannel, responseChannel) + } + requestJob.cancel() + responseJob.cancel() + if (!requestChannel.isClosedForReceive) requestChannel.close() + if (!responseChannel.isClosedForSend) responseChannel.close() + } + } + } catch (e: Exception) { + console.log(e) + } + if (!requestChannel.isClosedForReceive) requestChannel.close() + if (!responseChannel.isClosedForSend) responseChannel.close() + socket.close() + } + + /** + * @suppress internal function + */ + suspend fun Socket.receiveOrNull(): String? { + return try { + this.receive() + } catch (e: SocketClosedException) { + console.log("Socket was closed: ${e.reason}") + null + } + } + + /** + * @suppress internal function + */ + fun Socket.sendOrFalse(str: String): Boolean { + return try { + this.send(str) + true + } catch (e: SocketClosedException) { + console.log("Socket was closed: ${e.reason}") + false + } + } + + /** + * @suppress internal function + */ + @Suppress("TooGenericExceptionCaught") + suspend fun exceptionHelper(block: suspend () -> Unit) { + try { + block() + } catch (e: Exception) { + console.log(e) + } + } + + /** + * @suppress + * Internal function + */ + inline fun <reified PAR> serialize(value: PAR): String? { + return value?.let { + @Suppress("UNCHECKED_CAST") + trySerialize((PAR::class as KClass<Any>), it as Any) + } + } + +} diff --git a/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/RemoteAgent.kt b/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/RemoteAgent.kt new file mode 100644 index 00000000..fba6356c --- /dev/null +++ b/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/RemoteAgent.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2017-present Robert Jaros + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package pl.treksoft.kvision.remote + +import kotlinx.serialization.ImplicitReflectionSerializer +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.list +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.serializer +import pl.treksoft.kvision.types.JsonDateSerializer +import pl.treksoft.kvision.types.toStringInternal +import kotlin.js.Date +import kotlin.reflect.KClass + +class NotStandardTypeException(type: String) : Exception("Not a standard type: $type!") + +class NotEnumTypeException : Exception("Not the Enum type!") + +/** + * Interface for client side agent for JSON-RPC remote calls. + */ +interface RemoteAgent { + + /** + * @suppress + * Internal function + */ + @OptIn(ImplicitReflectionSerializer::class) + @Suppress("ComplexMethod", "TooGenericExceptionCaught", "NestedBlockDepth") + fun trySerialize(kClass: KClass<Any>, value: Any): String { + return if (value is List<*>) { + if (value.size > 0) { + when { + value[0] is String -> + @Suppress("UNCHECKED_CAST") + JSON.plain.stringify(String.serializer().list as KSerializer<Any>, value) + value[0] is Date -> + @Suppress("UNCHECKED_CAST") + JSON.plain.stringify(JsonDateSerializer.list as KSerializer<Any>, value) + value[0] is Int -> + @Suppress("UNCHECKED_CAST") + JSON.plain.stringify(Int.serializer().list as KSerializer<Any>, value) + value[0] is Long -> + @Suppress("UNCHECKED_CAST") + JSON.plain.stringify(Long.serializer().list as KSerializer<Any>, value) + value[0] is Boolean -> + @Suppress("UNCHECKED_CAST") + JSON.plain.stringify(Boolean.serializer().list as KSerializer<Any>, value) + value[0] is Float -> + @Suppress("UNCHECKED_CAST") + JSON.plain.stringify(Float.serializer().list as KSerializer<Any>, value) + value[0] is Double -> + @Suppress("UNCHECKED_CAST") + JSON.plain.stringify(Double.serializer().list as KSerializer<Any>, value) + value[0] is Char -> + @Suppress("UNCHECKED_CAST") + JSON.plain.stringify(Char.serializer().list as KSerializer<Any>, value) + value[0] is Short -> + @Suppress("UNCHECKED_CAST") + JSON.plain.stringify(Short.serializer().list as KSerializer<Any>, value) + value[0] is Byte -> + @Suppress("UNCHECKED_CAST") + JSON.plain.stringify(Byte.serializer().list as KSerializer<Any>, value) + value[0] is Enum<*> -> "[" + value.joinToString(",") { "\"$it\"" } + "]" + else -> try { + @Suppress("UNCHECKED_CAST") + JSON.plain.stringify(kClass.serializer().list as KSerializer<Any>, value) + } catch (e: Throwable) { + try { + @Suppress("UNCHECKED_CAST") + JSON.plain.stringify(value[0]!!::class.serializer().list as KSerializer<Any>, value) + } catch (e: Throwable) { + try { + @Suppress("UNCHECKED_CAST") + JSON.plain.stringify(String.serializer().list as KSerializer<Any>, value) + } catch (e: Throwable) { + value.toString() + } + } + } + } + } else { + "[]" + } + } else { + when (value) { + is Enum<*> -> "\"$value\"" + is String -> value + is Char -> "\"$value\"" + is Date -> "\"${value.toStringInternal()}\"" + else -> try { + @Suppress("UNCHECKED_CAST") + JSON.plain.stringify(kClass.serializer(), value) + } catch (e: Throwable) { + value.toString() + } + } + } + } + + /** + * @suppress + * Internal function + */ + @Suppress("UNCHECKED_CAST", "ComplexMethod") + fun <RET> deserialize(value: String, jsType: String): RET { + return when (jsType) { + "String" -> JSON.plain.parse(String.serializer(), value) as RET + "Number" -> JSON.plain.parse(Double.serializer(), value) as RET + "Long" -> JSON.plain.parse(Long.serializer(), value) as RET + "Boolean" -> JSON.plain.parse(Boolean.serializer(), value) as RET + "BoxedChar" -> JSON.plain.parse(Char.serializer(), value) as RET + "Short" -> JSON.plain.parse(Short.serializer(), value) as RET + "Date" -> JSON.plain.parse(JsonDateSerializer, value) as RET + "Byte" -> JSON.plain.parse(Byte.serializer(), value) as RET + else -> throw NotStandardTypeException(jsType) + } + } + + /** + * @suppress + * Internal function + */ + @Suppress("UNCHECKED_CAST", "ComplexMethod") + fun <RET> deserializeList(value: String, jsType: String): List<RET> { + return when (jsType) { + "String" -> JSON.plain.parse(String.serializer().list, value) as List<RET> + "Number" -> JSON.plain.parse(Double.serializer().list, value) as List<RET> + "Long" -> JSON.plain.parse(Long.serializer().list, value) as List<RET> + "Boolean" -> JSON.plain.parse(Boolean.serializer().list, value) as List<RET> + "BoxedChar" -> JSON.plain.parse(Char.serializer().list, value) as List<RET> + "Short" -> JSON.plain.parse(Short.serializer().list, value) as List<RET> + "Date" -> JSON.plain.parse(JsonDateSerializer.list, value) as List<RET> + "Byte" -> JSON.plain.parse(Byte.serializer().list, value) as List<RET> + else -> throw NotStandardTypeException(jsType) + } + } + + /** + * @suppress + * Internal function + */ + @Suppress("TooGenericExceptionCaught", "ThrowsCount") + fun tryDeserializeEnum(kClass: KClass<Any>, value: String): Any { + return try { + if (kClass.asDynamic().jClass.`$metadata$`.interfaces[0].name == "Enum") { + findEnumValue(kClass, JSON.plain.parse(String.serializer(), value)) ?: throw NotEnumTypeException() + } else { + throw NotEnumTypeException() + } + } catch (e: Throwable) { + throw NotEnumTypeException() + } + } + + /** + * @suppress + * Internal function + */ + @Suppress("TooGenericExceptionCaught", "ThrowsCount") + fun tryDeserializeEnumList(kClass: KClass<Any>, value: String): List<Any> { + return try { + if (kClass.asDynamic().jClass.`$metadata$`.interfaces[0].name == "Enum") { + JSON.plain.parse(String.serializer().list, value).map { + findEnumValue(kClass, JSON.plain.parse(String.serializer(), it)) ?: throw NotEnumTypeException() + } + } else { + throw NotEnumTypeException() + } + } catch (e: Throwable) { + throw NotEnumTypeException() + } + } + + fun findEnumValue(kClass: KClass<Any>, value: String): Any? { + return (kClass.asDynamic().jClass.values() as Array<Any>).find { + it.asDynamic().name == value + } + } +} diff --git a/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/Security.kt b/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/Security.kt new file mode 100644 index 00000000..fd2b5cb0 --- /dev/null +++ b/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/Security.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2017-present Robert Jaros + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package pl.treksoft.kvision.remote + +import kotlinx.coroutines.asDeferred +import kotlinx.serialization.Serializable + +/** + * A security exception. + */ +class SecurityException(message: String) : Exception(message) + +/** + * Username and password credentials. + */ +@Serializable +data class Credentials(val username: String? = null, val password: String? = null) + +/** + * Form login dispatcher. + */ +class LoginService(val loginEndpoint: String) { + val loginAgent = CallAgent() + + /** + * Login with a form. + * @param credentials username and password credentials + */ + suspend fun login(credentials: Credentials?): Boolean = + if (credentials?.username != null) { + loginAgent.remoteCall(loginEndpoint, obj { + this.username = credentials.username + this.password = credentials.password + }, HttpMethod.POST, "application/x-www-form-urlencoded").then { _: dynamic -> true }.asDeferred().await() + } else { + throw SecurityException("Credentials cannot be empty") + } +} + +/** + * Form login dispatcher. + */ +abstract class SecurityMgr { + + private var isLoggedIn = false + + /** + * Executes given block of code after successful authentication. + * @param block a block of code + */ + @Suppress("NestedBlockDepth", "TooGenericExceptionCaught") + suspend fun <T> withAuth(block: suspend () -> T): T { + return try { + block().also { + if (!isLoggedIn) { + isLoggedIn = true + afterLogin() + } + } + } catch (e: Exception) { + if (e is SecurityException || !isLoggedIn) { + afterError() + isLoggedIn = false + while (!isLoggedIn) { + try { + login() + isLoggedIn = true + afterLogin() + } catch (e: Throwable) { + console.log(e) + } + } + block() + } else { + throw e + } + } + } + + /** + * Login user. + * @return true if login is successful + * @throws SecurityException if login is not successful + */ + abstract suspend fun login(): Boolean + + /** + * Method called after successful login. + */ + open suspend fun afterLogin() {} + + /** + * Method called after error. + */ + open suspend fun afterError() {} +} diff --git a/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/Socket.kt b/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/Socket.kt new file mode 100644 index 00000000..f668d57a --- /dev/null +++ b/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/Socket.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2017-present Robert Jaros + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package pl.treksoft.kvision.remote + +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.w3c.dom.CloseEvent +import org.w3c.dom.ErrorEvent +import org.w3c.dom.MessageEvent +import org.w3c.dom.WebSocket +import org.w3c.dom.WebSocket.Companion.CLOSED +import org.w3c.dom.WebSocket.Companion.CLOSING +import org.w3c.dom.WebSocket.Companion.OPEN +import org.w3c.dom.events.Event +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +// +// Code taken from: https://discuss.kotlinlang.org/t/js-coroutines-experiements/8245/2 +// + +/** + * Websocket closed exception class. + */ +class SocketClosedException(val reason: String) : Throwable(reason) + +/** + * A websocket client implementation. + */ +class Socket { + private var eventQueue: Channel<Event> = Channel(Channel.UNLIMITED) + private lateinit var ws: WebSocket + val state: Short + get() = ws.readyState + + private fun onWsEvent(event: Event) { + GlobalScope.launch { eventQueue.send(event) } + } + + /** + * Connect to a websocket. + */ + suspend fun connect(url: String, retryDelay: Long = 1000) { + while (true) { + val connected = suspendCoroutine<Boolean> { cont -> + while (eventQueue.poll() != null) {/*drain*/ + } + ws = WebSocket(url) + ws.onopen = { + ws.onclose = ::onWsEvent + ws.onerror = ::onWsEvent + cont.resume(true) + } + ws.onmessage = ::onWsEvent + ws.onerror = { + logError(it) + } + ws.onclose = { + cont.resume(false) + } + } + if (connected) + break + else + delay(retryDelay) + } + } + + /** + * Receive a string from a websocket. + */ + @Suppress("ThrowsCount", "MagicNumber") + suspend fun receive(): String { + return when (val event = eventQueue.receive()) { + is MessageEvent -> { + event.data as String + } + is CloseEvent -> { + val reason = getReason(event.code) + throw SocketClosedException(reason) + } + is ErrorEvent -> { + logError(event) + close() + throw SocketClosedException(event.message) + } + else -> { + val reason = getReason(4001) + console.error(reason) + close() + throw SocketClosedException(reason) + } + } + } + + /** + * Send string to a websocket. + */ + @Suppress("MagicNumber") + fun send(obj: String) { + when { + isClosed() -> { + console.error(getReason(4002)) + throw SocketClosedException(getReason(4002)) + } + else -> ws.send(obj) + } + } + + /** + * Close a websocket. + */ + @Suppress("MagicNumber") + fun close(code: Short = 1000) { + when (state) { + OPEN -> ws.close(code, getReason(1000)) + } + } + + /** + * Returns if a websocket is closed. + */ + fun isClosed(): Boolean { + return when (state) { + CLOSED, CLOSING -> true + else -> false + } + } + + private fun logError(event: Event) = console.error("An error %o occurred when connecting to ${ws.url}", event) + + @Suppress("ComplexMethod", "MagicNumber") + private fun getReason(code: Short): String { + return when (code.toInt()) { // See http://tools.ietf.org/html/rfc6455#section-7.4.1 + 1000 -> "Normal closure" + 1001 -> "An endpoint is \"going away\", such as a server going down or " + + "a browser having navigated away from a page." + 1002 -> "An endpoint is terminating the connection due to a protocol error" + 1003 -> "An endpoint is terminating the connection because it has received a type of data it cannot " + + "accept (e.g., an endpoint that understands only text data MAY send this if it receives a " + + "binary message)." + 1004 -> "Reserved. The specific meaning might be defined in the future." + 1005 -> "No status code was actually present." + 1006 -> "The connection was closed abnormally, e.g., without sending or receiving a Close control frame" + 1007 -> "An endpoint is terminating the connection because it has received data within a message that " + + "was not consistent with the type of the message (e.g., non-UTF-8 " + + "[http://tools.ietf.org/html/rfc3629] data within a text message)." + 1008 -> "An endpoint is terminating the connection because it has received a message that " + + "\"violates its policy\". This reason is given either if there is no other sutible reason, or " + + "if there is a need to hide specific details about the policy." + 1009 -> "An endpoint is terminating the connection because it has received a message that is too big " + + "for it to process." + 1010 -> "An endpoint (client ) is terminating the connection because it has expected the server to " + + "negotiate one or more extension, but the server didn't return them in the response message of " + + "the WebSocket handshake. <br /> Specifically, the extensions that are needed are: " + 1011 -> "A server is terminating the connection because it encountered an unexpected condition that " + + "prevented it from fulfilling the request." + 1015 -> "The connection was closed due to a failure to perform a TLS handshake (e.g., the server " + + "certificate can't be verified)." + 4001 -> "Unexpected event" + 4002 -> "You are trying to use closed socket" + else -> "Unknown reason" + } + } +} diff --git a/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/Utils.kt b/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/Utils.kt new file mode 100644 index 00000000..98fbaea8 --- /dev/null +++ b/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/remote/Utils.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2017-present Robert Jaros + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package pl.treksoft.kvision.remote + +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonConfiguration +import kotlinx.serialization.modules.serializersModuleOf +import pl.treksoft.kvision.types.JsonDateSerializer +import kotlin.browser.window +import kotlin.js.Date + +/** + * JavaScript Object type + */ +external class Object + +/** + * Helper function for creating JavaScript objects. + */ +fun obj(init: dynamic.() -> Unit): dynamic { + return (Object()).apply(init) +} + +/** + * JSON utility functions + */ +object JSON { + + val plain = Json(context = serializersModuleOf(Date::class, JsonDateSerializer)) + + val nonstrict = Json( + configuration = JsonConfiguration.Stable.copy(ignoreUnknownKeys = true), + context = serializersModuleOf(Date::class, JsonDateSerializer) + ) + + /** + * An extension function to convert Serializable object to JS dynamic object + * @param serializer a serializer for T + */ + fun <T> T.toObj(serializer: SerializationStrategy<T>): dynamic { + return kotlin.js.JSON.parse(plain.stringify(serializer, this)) + } +} + +/** + * Creates a websocket URL from current window.location and given path. + */ +fun getWebSocketUrl(url: String): String { + val location = window.location + val scheme = if (location.protocol == "https:") "wss" else "ws" + val port = if (location.port == "8088") ":8080" + else if (location.port != "0" && location.port != "") ":${location.port}" else "" + return "$scheme://${location.hostname}$port$url" +} diff --git a/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/types/Date.kt b/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/types/Date.kt new file mode 100644 index 00000000..715eab6c --- /dev/null +++ b/kvision-modules/kvision-common-remote/src/jsMain/kotlin/pl/treksoft/kvision/types/Date.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2017-present Robert Jaros + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package pl.treksoft.kvision.types + +import kotlinx.serialization.Decoder +import kotlinx.serialization.Encoder +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialDescriptor +import kotlin.js.Date +import kotlin.math.absoluteValue + + +/** + * JSON date serializer. + */ +internal object JsonDateSerializer : KSerializer<Date> { + override val descriptor: SerialDescriptor = SerialDescriptor("kotlin.js.Date") + + override fun deserialize(decoder: Decoder): Date { + return decoder.decodeString().toDateInternal() + } + + override fun serialize(encoder: Encoder, value: Date) { + encoder.encodeString(value.toStringInternal()) + } +} + +@Suppress("ComplexMethod", "MagicNumber") +internal fun String.toDateInternal(): Date { + val dt = this.split(':', 'T', '-', '+') + val utcCheck = this[length - 1] == 'Z' + val ds = if (utcCheck) dt[5].dropLast(1).split(".") else dt[5].split(".") + val tzCheck = this[length - 6] + return if (!utcCheck && tzCheck != '-' && tzCheck != '+') { + Date( + dt[0].toInt(), + dt[1].toInt() - 1, + dt[2].toInt(), + dt[3].toInt(), + dt[4].toInt(), + ds[0].toInt(), + if (ds.size == 2) ds[1].toInt() else 0 + ) + } else { + val sign = if (utcCheck || tzCheck == '+') 1 else -1 + Date( + Date.UTC( + dt[0].toInt(), + dt[1].toInt() - 1, + dt[2].toInt(), + if (utcCheck) { + dt[3].toInt() + } else { + dt[3].toInt() - sign * dt[6].toInt() + }, + dt[4].toInt(), + ds[0].toInt(), + if (ds.size == 2) ds[1].toInt() else 0 + ) + ) + } +} + +internal fun Date.toStringInternal(): String { + @Suppress("MagicNumber") + val tz = this.getTimezoneOffset() / 60 + val sign = if (tz > 0) "-" else "+" + return "" + this.getFullYear() + "-" + ("0" + (this.getMonth() + 1)).takeLast(2) + "-" + + ("0" + this.getDate()).takeLast(2) + "T" + ("0" + this.getHours()).takeLast(2) + ":" + + ("0" + this.getMinutes()).takeLast(2) + ":" + ("0" + this.getSeconds()).takeLast(2) + "." + + this.getMilliseconds() + sign + ("0${tz.absoluteValue}").takeLast(2) + ":00" +} diff --git a/kvision-modules/kvision-common-remote/src/main/kotlin/pl/treksoft/kvision/remote/KVServiceManager.kt b/kvision-modules/kvision-common-remote/src/main/kotlin/pl/treksoft/kvision/remote/KVServiceManager.kt deleted file mode 100644 index 71c7d641..00000000 --- a/kvision-modules/kvision-common-remote/src/main/kotlin/pl/treksoft/kvision/remote/KVServiceManager.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright (c) 2017-present Robert Jaros - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package pl.treksoft.kvision.remote - -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.SendChannel -import kotlin.reflect.KClass - -enum class HttpMethod { - GET, - POST, - PUT, - DELETE, - OPTIONS -} - -/** - * Multiplatform service manager. - */ -expect open class KVServiceManager<T : Any>(serviceClass: KClass<T>) { - - /** - * Binds a given route with a function of the receiver. - * @param function a function of the receiver - * @param method a HTTP method - * @param route a route - */ - protected inline fun <reified RET> bind( - noinline function: suspend T.() -> RET, - method: HttpMethod = HttpMethod.POST, - route: String? = null - ) - - /** - * Binds a given route with a function of the receiver. - * @param function a function of the receiver - * @param method a HTTP method - * @param route a route - */ - protected inline fun <reified PAR, reified RET> bind( - noinline function: suspend T.(PAR) -> RET, - method: HttpMethod = HttpMethod.POST, - route: String? = null - ) - - /** - * Binds a given route with a function of the receiver. - * @param function a function of the receiver - * @param method a HTTP method - * @param route a route - */ - protected inline fun <reified PAR1, reified PAR2, reified RET> bind( - noinline function: suspend T.(PAR1, PAR2) -> RET, - method: HttpMethod = HttpMethod.POST, - route: String? = null - ) - - /** - * Binds a given route with a function of the receiver. - * @param function a function of the receiver - * @param method a HTTP method - * @param route a route - */ - protected inline fun <reified PAR1, reified PAR2, reified PAR3, reified RET> bind( - noinline function: suspend T.(PAR1, PAR2, PAR3) -> RET, - method: HttpMethod = HttpMethod.POST, - route: String? = null - ) - - /** - * Binds a given route with a function of the receiver. - * @param function a function of the receiver - * @param method a HTTP method - * @param route a route - */ - protected inline fun <reified PAR1, reified PAR2, reified PAR3, reified PAR4, reified RET> bind( - noinline function: suspend T.(PAR1, PAR2, PAR3, PAR4) -> RET, - method: HttpMethod = HttpMethod.POST, - route: String? = null - ) - - /** - * Binds a given route with a function of the receiver. - * @param function a function of the receiver - * @param method a HTTP method - * @param route a route - */ - protected inline fun <reified PAR1, reified PAR2, reified PAR3, reified PAR4, reified PAR5, reified RET> bind( - noinline function: suspend T.(PAR1, PAR2, PAR3, PAR4, PAR5) -> RET, - method: HttpMethod = HttpMethod.POST, - route: String? = null - ) - - /** - * Binds a given function of the receiver as a tabulator component source - * @param function a function of the receiver - */ - protected inline fun <reified RET> bindTabulatorRemote( - noinline function: suspend T.(Int?, Int?, List<RemoteFilter>?, List<RemoteSorter>?, String?) -> RemoteData<RET> - ) - - /** - * Binds a given function of the receiver as a web socket connection - * @param function a function of the receiver - */ - protected inline fun <reified PAR1 : Any, reified PAR2 : Any> bind( - noinline function: suspend T.(ReceiveChannel<PAR1>, SendChannel<PAR2>) -> Unit, - route: String? = null - ) -} |