diff --git a/README.md b/README.md index 3686351dbfb27bc7585a6395f703ef2a4fe8c4dd..6a8c78bd93606ae4a6d5ef69fa140692d063dc86 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [](https://git.ucsc.edu/wnwalker/temerity/-/packages) [](https://git.ucsc.edu/wnwalker/temerity/-/pipelines) [](https://git.ucsc.edu/wnwalker/temerity/-/issues) -[](http://kotlinlang.org) +[](http://kotlinlang.org) [](https://gradle.org) [](https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c9cac7a48116769bcc6169e1fa1f22ee35281cd0..4cc239f93dca70175de051edbb1e6c5364011575 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,25 +2,28 @@ compose-multiplatform = "1.7.1" agp = "8.2.2" java = "17" -kotlin = "2.0.21" -ksp = "2.0.21-1.0.28" +kotlin = "2.1.0" +ksp = "2.1.0-1.0.29" -ktor = "3.0.1" -kotlinx-serialization = "1.7.3" +kotlinx-serialization = "1.8.0-RC" +ktor = "3.0.3" cache4k = "0.13.0" +statelyConcurrentCollections = "2.1.0" slf4j = "2.0.16" - +kotlinx-io = "0.6.0" ktorfit = "2.2.0" -coroutines = "1.9.0" +coroutines = "1.10.1" datetime = "0.6.1" sandwich = "2.0.10" arrow = "1.2.4" # Main Koin version - for core, android deps -koin = "4.0.0" -koinTest = "4.0.0" +koin = "4.0.1" +koinTest = "4.0.1" +gradleBuildConfigPlugin = "5.5.1" +akkurate = "0.11.0" # Koin version for Compose multiplatform -koinComposeMultiplatform = "4.0.0" +koinComposeMultiplatform = "4.0.1" mosaic = "0.13.0" molecule = "2.0.0" adaptive = "1.0.1" @@ -42,8 +45,11 @@ jline = "3.27.1" appdirs = "1.2.0" kstore = "0.9.1" kmpIo = "0.1.5" +kotlinSemver = "2.0.0" +jansi = "2.4.1" +temerity = "[0.1.0-dev0z+41180a5,0.1.0]" -kotlinx-dataframe = "0.13.1" +kotlinx-dataframe = "0.14.2" klaxon = "5.6" kotest = "6.0.0.M1" kotest-datatest = "5.9.1" @@ -54,7 +60,7 @@ alertKmp = "1.0.7" filekit = "0.8.7" composetray = "0.4.0" -dokka = "2.0.0-Beta" +dokka = "2.0.0" spotless = "7.0.0.BETA4" gitSemVer = "3.1.7" conventionalCommits = "1.0.12" @@ -74,7 +80,10 @@ ktor-client-java = { group = "io.ktor", name = "ktor-client-java", version.ref = ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" } ktor-test = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" } cache4k = { group = "io.github.reactivecircus.cache4k", name = "cache4k", version.ref = "cache4k" } +statelyConcurrentCollections = { group = "co.touchlab", name = "stately-concurrent-collections", version.ref = "statelyConcurrentCollections" } slf4j = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } +slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } + kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } @@ -82,6 +91,7 @@ kotlinx-coroutines-debug = { module = "org.jetbrains.kotlinx:kotlinx-coroutines- kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } kotlinx-coroutines-slf4j = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-slf4j", version.ref = "coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } +kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" } ktorfit-lib = { module = "de.jensklingenberg.ktorfit:ktorfit-lib-light", version.ref = "ktorfit" } @@ -93,6 +103,9 @@ appdirs = { module = "ca.gosyer:kotlin-multiplatform-appdirs", version.ref = "ap kstore = { module = "io.github.xxfast:kstore", version.ref = "kstore" } kstore-file = { module = "io.github.xxfast:kstore-file", version.ref = "kstore" } kmpIo = { module = "io.github.skolson:kmp-io", version.ref = "kmpIo" } +kotlinSemver = { module = "io.github.z4kn4fein:semver", version.ref = "kotlinSemver" } +jansi = { module = "org.fusesource.jansi:jansi", version.ref = "jansi" } +temerity = { module = "edu.ucsc.its:temerity", version.ref = "temerity" } sandwich = { module = "com.github.skydoves:sandwich", version.ref = "sandwich" } sandwich-ktor = { module = "com.github.skydoves:sandwich-ktor", version.ref = "sandwich" } @@ -104,6 +117,7 @@ arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" } arrow-fxCoroutines = { module = "io.arrow-kt:arrow-fx-coroutines", version.ref = "arrow" } arrow-optics = { module = "io.arrow-kt:arrow-optics", version.ref = "arrow" } arrow-opticsKspPlugin = { module = "io.arrow-kt:arrow-optics-ksp-plugin", version.ref = "arrow" } +akkurate = { module = "dev.nesk.akkurate:akkurate-core", version.ref = "akkurate" } mosaic = { module = "com.jakewharton.mosaic:mosaic-runtime", version.ref = "mosaic" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koinComposeMultiplatform" } @@ -183,6 +197,7 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ktorfit = { id = "de.jensklingenberg.ktorfit", version.ref = "ktorfit" } kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotestMultiplatform = { id = "io.kotest.multiplatform", version.ref = "kotest" } +buildConfig = { id = "com.github.gmazzo.buildconfig", version.ref = "gradleBuildConfigPlugin" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } gitSemVer = { id = "org.danilopianini.git-sensitive-semantic-versioning", version.ref = "gitSemVer" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 51e1835fae0d4507b3624fc2cc6601091c23176b..d9a15e5af808390c423889b46d8a9cd7a95ecf1d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,6 +25,7 @@ dependencyResolutionManagement { maven("https://maven.pkg.jetbrains.space/public/p/ktor/eap") maven("https://maven.pkg.jetbrains.space/kotlin/p/kotlin/dev") maven("https://plugins.gradle.org/m2") + maven("https://git.ucsc.edu/api/v4/projects/12162/packages/maven") } } diff --git a/shared/shared/build.gradle.kts b/shared/shared/build.gradle.kts index c74b195f04e69d495e193739b1d539175ac8af11..75bd6ae91b60ee86f6d024c73e43c0d6e59c05e8 100644 --- a/shared/shared/build.gradle.kts +++ b/shared/shared/build.gradle.kts @@ -54,7 +54,7 @@ kotlin { sourceSets { val commonMain by getting { dependencies { - api(project(":temerity")) + api(libs.temerity) api(libs.koin.core) implementation(libs.koin.composeVm) diff --git a/temerity/NOTICE b/temerity/NOTICE index 63f8d0fa9d96698bafee25b7a2a884655b75c898..9ad9767805d081fcc63003a80956b53cf79bbef6 100644 --- a/temerity/NOTICE +++ b/temerity/NOTICE @@ -1,7 +1,34 @@ -This product includes software developed by Ãlvaro Salcedo GarcÃa +This library includes portions of software developed by John O'Reilly et. al.: https://github.com/joreilly/FantasyPremierLeague/ + +Function(s): buildHttpClient() +File(s): commonMain/Temerity.kt +Description: A method which builds and returns a Ktor HttpClient given a request engine, engine config, and logger +License: Apache License, Version 2.0 + +-------------------------------------------- + +This library includes portions of software developed by Chris Krueger et. al: https://github.com/ChrisKruegerDev/tmdb-kotlin + +Function(s): buildHttpClient() +File(s): commonMain/Temerity.kt +Description: A method which builds and returns a Ktor HttpClient given a request engine, engine config, and logger +License: Apache License, Version 2.0 + +-------------------------------------------- + +This library includes software developed by Ãlvaro Salcedo GarcÃa File(s): jvmTest/Util.kt Description: A utility function for configuring Koin DI context in Kotest tests. License: Apache License, Version 2.0 +-------------------------------------------- + +This library includes software developed by Paul Hawke et. al: https://github.com/psh/KermitExt + +File(s): /extensions/log/*.kt +Description: A set of extension functions for Kermit, a Kotlin multiplatform logging library. +These allow for custom log file writers, builders, and framework binders. +License: Apache License, Version 2.0 + -------------------------------------------- \ No newline at end of file diff --git a/temerity/TemerityDemo.ipynb b/temerity/TemerityDemo.ipynb index 55b1a462c121edb5bb154f47c948aea6d1df8f95..48c19dbce2243aad4fab3b1df87bb2ab2e34525e 100644 --- a/temerity/TemerityDemo.ipynb +++ b/temerity/TemerityDemo.ipynb @@ -2,27 +2,104 @@ "cells": [ { "cell_type": "code", - "execution_count": null, "metadata": { - "collapsed": true + "collapsed": true, + "ExecuteTime": { + "end_time": "2024-12-04T17:26:08.492177800Z", + "start_time": "2024-12-04T17:26:08.025486300Z" + } }, - "outputs": [], "source": [ - "import edu.ucsc.its.temerity.PlatformClient\n", + "// README: Execute this notebook with the temerity.shared.shared.jvmMain source set active ^^^\n", + "import edu.ucsc.its.temerity.TemClientConfig\n", + "import edu.ucsc.its.temerity.core.Temerity\n", + "import io.github.cdimascio.dotenv.Dotenv\n", "import kotlinx.coroutines.runBlocking\n", - " \n", - "startTemerity()\n", - "val client = platformClient {\n", - " serviceEndpoint(\"https://your_organization.yuja.com/services/\")\n", - " serviceToken(\"your_API_token\") }\n", - "runBlocking { client.getUsers().forEach { println(it) } }\n", + "import org.dotenv.vault.dotenvVault\n", "\n", - "val returnedDevices = runBlocking { client.getDevices() }\n", - "println(\"Returned devices:\")\n", - "returnedDevices.forEach { println(it) }\n", + "fun TemClientConfig.configureDevNotebookEnvironment(dotenv: Dotenv) {\n", + " serviceUrl = dotenv[\"YUJADEV_API_URL\"]\n", + " serviceToken = dotenv[\"YUJADEV_TOKEN\"]\n", + " optDebugEnabled = true\n", + " supportKtxNotebook = true\n", + "}\n", "\n", - "stopTemerity()" - ] + "\n", + "\n", + "//val temerityClient = Temerity {\n", + "// serviceUrl = \"https://your_organization.yuja.com/services/\"\n", + "// serviceToken = \"your_API_token\"\n", + "//}\n", + "\n", + "val temerityClient = Temerity {\n", + " configureDevNotebookEnvironment(dotenvVault())\n", + "}\n", + "runBlocking { temerityClient.refreshCachedUserRoles().joinToString(\", \").also { println() } }\n", + "\n", + "val returnedDevices = runBlocking { temerityClient.getDevices() }\n", + "println(\"Returned devices: ${ returnedDevices.joinToString(\", \") }\")\n", + "\n", + "\n", + "\n" + ], + "outputs": [ + { + "ename": "org.jetbrains.kotlinx.jupyter.exceptions.ReplCompilerException", + "evalue": "at Cell In[1], line 4, column 18: Unresolved reference: cdimascio\nat Cell In[1], line 6, column 12: Unresolved reference: dotenv\nat Cell In[1], line 8, column 61: Unresolved reference: Dotenv\nat Cell In[1], line 23, column 35: Unresolved reference: dotenvVault", + "output_type": "error", + "traceback": [ + "org.jetbrains.kotlinx.jupyter.exceptions.ReplCompilerException: at Cell In[1], line 4, column 18: Unresolved reference: cdimascio", + "at Cell In[1], line 6, column 12: Unresolved reference: dotenv", + "at Cell In[1], line 8, column 61: Unresolved reference: Dotenv", + "at Cell In[1], line 23, column 35: Unresolved reference: dotenvVault", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.JupyterCompilerImpl.compileSync(JupyterCompilerImpl.kt:208)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.InternalEvaluatorImpl.eval(InternalEvaluatorImpl.kt:126)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.CellExecutorImpl$execute$1$result$1.invoke(CellExecutorImpl.kt:80)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.CellExecutorImpl$execute$1$result$1.invoke(CellExecutorImpl.kt:78)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.withHost(ReplForJupyterImpl.kt:774)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.CellExecutorImpl.execute-L4Nmkdk(CellExecutorImpl.kt:78)", + "\tat org.jetbrains.kotlinx.jupyter.repl.execution.CellExecutor$DefaultImpls.execute-L4Nmkdk$default(CellExecutor.kt:13)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.evaluateUserCode-wNURfNM(ReplForJupyterImpl.kt:596)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.evalExImpl(ReplForJupyterImpl.kt:454)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.access$evalExImpl(ReplForJupyterImpl.kt:141)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl$evalEx$1.invoke(ReplForJupyterImpl.kt:447)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl$evalEx$1.invoke(ReplForJupyterImpl.kt:446)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.withEvalContext(ReplForJupyterImpl.kt:427)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.evalEx(ReplForJupyterImpl.kt:446)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1$1.invoke(IdeCompatibleMessageRequestProcessor.kt:171)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1$1.invoke(IdeCompatibleMessageRequestProcessor.kt:170)", + "\tat org.jetbrains.kotlinx.jupyter.streams.BlockingSubstitutionEngine.withDataSubstitution(SubstitutionEngine.kt:70)", + "\tat org.jetbrains.kotlinx.jupyter.streams.StreamSubstitutionManager.withSubstitutedStreams(StreamSubstitutionManager.kt:118)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.withForkedIn(IdeCompatibleMessageRequestProcessor.kt:347)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.access$withForkedIn(IdeCompatibleMessageRequestProcessor.kt:67)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$evalWithIO$1$1.invoke(IdeCompatibleMessageRequestProcessor.kt:361)", + "\tat org.jetbrains.kotlinx.jupyter.streams.BlockingSubstitutionEngine.withDataSubstitution(SubstitutionEngine.kt:70)", + "\tat org.jetbrains.kotlinx.jupyter.streams.StreamSubstitutionManager.withSubstitutedStreams(StreamSubstitutionManager.kt:118)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.withForkedErr(IdeCompatibleMessageRequestProcessor.kt:336)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.access$withForkedErr(IdeCompatibleMessageRequestProcessor.kt:67)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$evalWithIO$1.invoke(IdeCompatibleMessageRequestProcessor.kt:360)", + "\tat org.jetbrains.kotlinx.jupyter.streams.BlockingSubstitutionEngine.withDataSubstitution(SubstitutionEngine.kt:70)", + "\tat org.jetbrains.kotlinx.jupyter.streams.StreamSubstitutionManager.withSubstitutedStreams(StreamSubstitutionManager.kt:118)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.withForkedOut(IdeCompatibleMessageRequestProcessor.kt:328)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.evalWithIO(IdeCompatibleMessageRequestProcessor.kt:359)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1.invoke(IdeCompatibleMessageRequestProcessor.kt:170)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1.invoke(IdeCompatibleMessageRequestProcessor.kt:169)", + "\tat org.jetbrains.kotlinx.jupyter.execution.JupyterExecutorImpl$Task.execute(JupyterExecutorImpl.kt:41)", + "\tat org.jetbrains.kotlinx.jupyter.execution.JupyterExecutorImpl$executorThread$1.invoke(JupyterExecutorImpl.kt:81)", + "\tat org.jetbrains.kotlinx.jupyter.execution.JupyterExecutorImpl$executorThread$1.invoke(JupyterExecutorImpl.kt:79)", + "\tat kotlin.concurrent.ThreadsKt$thread$thread$1.run(Thread.kt:30)", + "" + ] + } + ], + "execution_count": 1 + }, + { + "metadata": {}, + "cell_type": "code", + "source": "", + "outputs": [], + "execution_count": null } ], "metadata": { @@ -41,26 +118,9 @@ "nbconvert_exporter": "" }, "ktnbPluginMetadata": { + "projectLibraries": false, "projectDependencies": [ - "Temerity.library.androidTest", - "Temerity.library.appleMain", - "Temerity.library.appleTest", - "Temerity.library.commonMain", - "Temerity.library.commonTest", - "Temerity.library.iosArm64Main", - "Temerity.library.iosArm64Test", - "Temerity.library.iosMain", - "Temerity.library.iosSimulatorArm64Main", - "Temerity.library.iosSimulatorArm64Test", - "Temerity.library.iosTest", - "Temerity.library.iosX64Main", - "Temerity.library.iosX64Test", - "Temerity.library.jvmMain", - "Temerity.library.jvmTest", - "Temerity.library.main", - "Temerity.library.nativeMain", - "Temerity.library.nativeTest", - "Temerity.library.unitTest" + "temerity.shared.shared.jvmMain" ] } }, diff --git a/temerity/build.gradle.kts b/temerity/build.gradle.kts index f98fbe7efe856748e5d37d83b0c0852cdaa019eb..62d36fb7861d2aed3a52e41b86aa2b1853b9b0ef 100644 --- a/temerity/build.gradle.kts +++ b/temerity/build.gradle.kts @@ -26,6 +26,7 @@ plugins { alias(libs.plugins.ktorfit) alias(libs.plugins.kotlinxSerialization) alias(libs.plugins.kotestMultiplatform) + alias(libs.plugins.buildConfig) id("convention.formattingLib") id("convention.version") @@ -43,6 +44,11 @@ buildscript { } } +buildConfig { + buildConfigField("LIB_VERSION", provider { "${project.version}" }) + buildConfigField("PACKAGE_NAME", provider { "edu.ucsc.its.${project.name}" }) +} + gitSemVer { commitNameBasedUpdateStrategy(ConventionalCommit::semanticVersionUpdate) } @@ -79,9 +85,11 @@ kotlin { implementation(libs.koin.core) implementation(libs.ktorfit.lib) implementation(libs.kotlinx.coroutines.core) - implementation(libs.kermit) - implementation(libs.cache4k) + api(libs.kermit) + // TODO: Re-add cache4k when updated for Kotlin 2.0.21 + implementation(libs.statelyConcurrentCollections) implementation(libs.kmpIo) + implementation(libs.kotlinx.io) } } val commonTest by getting { @@ -96,12 +104,14 @@ kotlin { val jvmMain by getting { dependencies { implementation(libs.ktor.client.java) - implementation(libs.slf4j) + implementation(libs.slf4j.api) + implementation(libs.jansi) } } val androidMain by getting { dependencies { implementation(libs.ktor.client.android) + // TODO: Add slf4f-api } } val jvmTest by getting { @@ -118,6 +128,8 @@ kotlin { implementation(libs.koin.test) implementation(libs.koin.test.junit5) + + implementation(libs.kotlinSemver) } } } diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/core/JsonFactory.kt b/temerity/src/androidMain/kotlin/edu/ucsc/its/temerity/core/LoggerFactory.android.kt similarity index 72% rename from temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/core/JsonFactory.kt rename to temerity/src/androidMain/kotlin/edu/ucsc/its/temerity/core/LoggerFactory.android.kt index 02f61f54d0c020a81d018f20a3fb4642f3659449..bb98a1e554585bbdc31135f62c2040b2caac7e78 100644 --- a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/core/JsonFactory.kt +++ b/temerity/src/androidMain/kotlin/edu/ucsc/its/temerity/core/LoggerFactory.android.kt @@ -17,15 +17,13 @@ */ package edu.ucsc.its.temerity.core -import kotlinx.serialization.json.Json +import co.touchlab.kermit.Logger +import edu.ucsc.its.temerity.TemClientConfig -/** - * Factory for creating a Ktor [Json] object. - * Adapted from the tmdb-api project: https://github.com/MoviebaseApp/tmdb-kotlin/raw/refs/heads/main/tmdb-api/src/commonMain/kotlin/app/moviebase/tmdb/core/JsonFactory.kt - */ -internal object JsonFactory { - - fun buildJson(): Json = Json { - coerceInputValues = true - } +internal actual fun createCommonLogger( + tag: String?, + config: TemClientConfig?, + supportKtxNotebook: Boolean, +): Logger { + TODO("Not yet implemented") } diff --git a/temerity/src/androidMain/kotlin/edu/ucsc/its/temerity/di/PlatformModule.android.kt b/temerity/src/androidMain/kotlin/edu/ucsc/its/temerity/di/PlatformModule.android.kt new file mode 100644 index 0000000000000000000000000000000000000000..1f3994819517bc487b1b88a47077c10fcb72ec73 --- /dev/null +++ b/temerity/src/androidMain/kotlin/edu/ucsc/its/temerity/di/PlatformModule.android.kt @@ -0,0 +1,31 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; version 2.1 of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package edu.ucsc.its.temerity.di + +import io.ktor.client.engine.android.Android +import kotlinx.coroutines.CoroutineDispatcher +import org.koin.core.qualifier.named +import org.koin.dsl.module + +internal actual fun platformModule() = module { + single(named("httpClientEngine")) { (libraryCoroutineDispatcher: CoroutineDispatcher) -> + Android.create { + dispatcher = libraryCoroutineDispatcher + } + } +} diff --git a/temerity/src/androidMain/kotlin/edu/ucsc/its/temerity/Actual.kt b/temerity/src/androidMain/kotlin/edu/ucsc/its/temerity/extensions/log/Filesystem.android.kt similarity index 83% rename from temerity/src/androidMain/kotlin/edu/ucsc/its/temerity/Actual.kt rename to temerity/src/androidMain/kotlin/edu/ucsc/its/temerity/extensions/log/Filesystem.android.kt index 5aac3efe907f7463ff9a2a3c9842b217b04eff4e..094b3d7530deffde589ca3ccadd23ad63130c62f 100644 --- a/temerity/src/androidMain/kotlin/edu/ucsc/its/temerity/Actual.kt +++ b/temerity/src/androidMain/kotlin/edu/ucsc/its/temerity/extensions/log/Filesystem.android.kt @@ -15,11 +15,9 @@ * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ -package edu.ucsc.its.temerity +package edu.ucsc.its.temerity.extensions.log -import io.ktor.client.engine.android.Android -import org.koin.dsl.module +import kotlinx.io.files.FileSystem +import kotlinx.io.files.SystemFileSystem -internal actual fun platformModule() = module { - single { Android.create() } -} +internal actual fun fileSystem(): FileSystem = SystemFileSystem diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/TemClientConfig.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/TemClientConfig.kt index a7372e974a21539852a64daba54998d237b4f674..491f006cd23c7ba5e4e3b89a6dc90309ea65ef9b 100644 --- a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/TemClientConfig.kt +++ b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/TemClientConfig.kt @@ -33,44 +33,50 @@ import kotlin.time.Duration * @property serviceToken Specifies the authorization token to use for API calls * @property optDebugEnabled Specifies whether debug logging should be enabled * - * @property expectSuccess Specifies whether to expect successful responses by default - * @property useWebTimeout Specifies whether to use a timeout other than default for web requests - * @property webTimeout Specifies the timeout [Duration] to use when making web requests - * @property cacheTimeout Specifies the timeout [Duration] to use when storing cache entries. Affects keep-alive time for cached instance data like user role type fields. + * @property optExpectSuccess Specifies whether to expect successful responses by default + * @property optUseWebTimeout Specifies whether to use a timeout other than default for web requests + * @property optWebTimeoutDuration Specifies the timeout [Duration] to use when making web requests + * @property optCacheTimeoutDuration Specifies the timeout [Duration] to use when storing cache entries. Affects keep-alive time for cached instance data like user role type fields. + * @property optThreadCount Specifies the number of threads to use for each client instance. Defaults to 2 at a minimum */ @TemDsl public class TemClientConfig { public var serviceUrl: String? = null public var serviceToken: String? = null + public var optLoggingEnabled: Boolean = true public var optDebugEnabled: Boolean = false + public var optSupportKtxNotebook: Boolean = false - public var expectSuccess: Boolean = true - public var useWebTimeout: Boolean = false + public var optExpectSuccess: Boolean = true + public var optUseWebTimeout: Boolean = false @Suppress("MemberVisibilityCanBePrivate") - public var webTimeout: Duration? = null + public var optWebTimeoutDuration: Duration? = null + @Suppress("MemberVisibilityCanBePrivate") // Default cache timeout is set to 15 minutes if this is left unspecified; @see [edu.ucsc.its.temerity.core.Temerity] init block - public var cacheTimeout: Duration? = null + public var optCacheTimeoutDuration: Duration? = null + + public var optThreadCount: Int? = null /** * Configures the HttpClient with the provided block. * @param () -> HttpClient The block to configure the HttpClient. */ - internal var httpClientConfigBlock: (HttpClientConfig<*>.() -> Unit)? = null - internal var httpClientBuilder: (() -> HttpClient)? = null - internal var httpClientLoggingBlock: (LoggingConfig.() -> Unit)? = null + internal var optHttpClientConfigBlock: (HttpClientConfig<*>.() -> Unit)? = null + internal var optHttpClientBuilder: (() -> HttpClient)? = null + internal var optHttpClientLoggingBlock: (LoggingConfig.() -> Unit)? = null public fun logging(block: LoggingConfig.() -> Unit) { - httpClientLoggingBlock = block + optHttpClientLoggingBlock = block } /** * Set custom HttpClient configuration for the default HttpClient. */ public fun httpClient(block: HttpClientConfig<*>.() -> Unit) { - httpClientConfigBlock = block + optHttpClientConfigBlock = block } /** @@ -81,13 +87,12 @@ public class TemClientConfig { engineFactory: HttpClientEngineFactory<T>, block: HttpClientConfig<T>.() -> Unit = {}, ) { - httpClientBuilder = { + optHttpClientBuilder = { HttpClient(engineFactory, block) } } public companion object { - internal fun withToken(serviceToken: String): TemClientConfig = TemClientConfig().apply { this.serviceToken = serviceToken } diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/TemerityApi.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/TemerityApi.kt index c0ab3d5dec0fcc35641200802be56d3ff6e6409a..93adda8dca388bae8e967d6cc332868bf3aaf126 100644 --- a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/TemerityApi.kt +++ b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/TemerityApi.kt @@ -17,6 +17,7 @@ */ package edu.ucsc.its.temerity +import edu.ucsc.its.temerity.extensions.datetime.DateTimeExt.currentDate import edu.ucsc.its.temerity.model.AuditLogEntry import edu.ucsc.its.temerity.model.Course import edu.ucsc.its.temerity.model.Device @@ -47,6 +48,13 @@ public interface TemerityApi { // User API public library functions + /** + * Fetches the version of the Temerity client library. + * @return The version of the client library as a semantic versioning formatted string. + */ + @Suppress("PropertyName") + public val version: String + /** * Fetches the list of users from the platform. * @return A list of User objects @@ -225,10 +233,14 @@ public interface TemerityApi { public suspend fun refreshCachedUserRoles(): List<String> - public suspend fun getCachedUserRoles(refresh: Boolean): List<String> + /** + * Fetches the user roles from the platform cached locally on the client. + * @param refresh Whether to fetch the roles if not present locally. + */ + public suspend fun getCachedUserRoles(refresh: Boolean = true): List<String> } -// Objects used by Temerity implementations, and possibly library consumers +// Objects used by library consumers and re-implementations of the Temerity API spec public val AUDIT_LOG_REQUEST_DATE_FORMAT: DateTimeFormat<LocalDate> = LocalDate.Format { dayOfMonth() diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/core/HttpClientFactory.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/core/HttpClientFactory.kt deleted file mode 100644 index 3adbd44ebfb7fd1108319b03781c16ee998e0722..0000000000000000000000000000000000000000 --- a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/core/HttpClientFactory.kt +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) - * Copyright 2022-2024 The Regents of the University of California. All rights reserved. - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; version 2.1 of the License. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ -package edu.ucsc.its.temerity.core - -import edu.ucsc.its.temerity.TemClientConfig -import edu.ucsc.its.temerity.core.TemerityLibrary.koin -import io.ktor.client.HttpClient -import io.ktor.client.HttpClientConfig -import io.ktor.client.engine.HttpClientEngine -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.plugins.defaultRequest -import io.ktor.client.plugins.logging.LogLevel -import io.ktor.client.plugins.logging.Logging -import io.ktor.client.request.header -import io.ktor.http.URLProtocol -import kotlin.time.DurationUnit -import co.touchlab.kermit.Logger as KermitLogger -import io.ktor.client.plugins.logging.Logger as KtorLogger -/** - * Factory providing web request clients for the Temerity client. - * Adapted from the tmdb-api project: https://github.com/MoviebaseApp/tmdb-kotlin/raw/refs/heads/main/tmdb-api/src/commonMain/kotlin/app/moviebase/tmdb/core/HttpClientFactory.kt - */ -internal object HttpClientFactory { - /** - * Adapted from https://github.com/joreilly/FantasyPremierLeague/ - * This function creates an HttpClient with the provided HttpClientEngine. - * The HttpClientEngine is injected by the Koin platformModule(), which as its name suggests provides an HttpClientEngine implementation that is platform-dependent. - * It configures the client to log all requests and responses if enableNetworkLogs is true. TODO: read this as a build setting - * It also sets a default request header with the provided authToken. - * @param httpClientEngine The HttpClientEngine to use for the HttpClient. - * @param timeoutDuration The duration in milliseconds to wait for a response before timing out. - * @param config A block specifying the following options: - * - Whether to log all requests and responses. - * - Service URL to make requests to. - * - Token to set as a default request header. - * @return The created HttpClient. - */ - internal fun buildHttpClient( - httpClientEngine: HttpClientEngine, - config: TemClientConfig, - ): HttpClient { - val defaultConfig: HttpClientConfig<*>.() -> Unit = { - // Can't use install(ContentNegotiation){ json() } here because YuJa's API returns a 406 error if the Accept header is set to application/json - // This requires JSON transformations to be done via manually-called library functions - - // Pass the stored authToken as a header in every request - defaultRequest { - header("authToken", config.serviceToken) - url { - protocol = URLProtocol.HTTPS - } - } - - if (config.optDebugEnabled) { - if (config.httpClientLoggingBlock == null) { - install(Logging) { - val kermit = koin.get<KermitLogger>() - logger = object : KtorLogger { - override fun log(message: String) { - kermit.d(message) - } - } - level = LogLevel.ALL - sanitizeHeader { headerKey -> - headerKey == "authToken" - } - } - } else { - config.httpClientLoggingBlock?.let { - Logging(it) - } - } - } - - expectSuccess = config.expectSuccess - - if (config.useWebTimeout) { - val defaultWebTimeout = TemerityLibrary.DEFAULT_WEB_TIMEOUT.toLong(DurationUnit.MILLISECONDS) - val configuredWebTimeout = config.webTimeout?.toLong(DurationUnit.MILLISECONDS) - install(HttpTimeout) { - connectTimeoutMillis = configuredWebTimeout ?: defaultWebTimeout - requestTimeoutMillis = configuredWebTimeout ?: defaultWebTimeout - socketTimeoutMillis = configuredWebTimeout ?: defaultWebTimeout - } - } - - config.httpClientConfigBlock?.invoke(this) - } - return config.httpClientBuilder?.invoke()?.config(defaultConfig) ?: HttpClient(httpClientEngine, defaultConfig) - } -} diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/core/Library.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/core/Library.kt deleted file mode 100644 index b074f8808843fb6d7ec83e1d12d94dbee7cb5f0f..0000000000000000000000000000000000000000 --- a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/core/Library.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) - * Copyright 2022-2024 The Regents of the University of California. All rights reserved. - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; version 2.1 of the License. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ -package edu.ucsc.its.temerity.core - -import edu.ucsc.its.temerity.TemClientConfig -import edu.ucsc.its.temerity.di.libModule -import edu.ucsc.its.temerity.di.loggerModule -import edu.ucsc.its.temerity.platformModule -import org.koin.core.Koin -import org.koin.dsl.koinApplication -import kotlin.time.Duration -import kotlin.time.Duration.Companion.minutes - -/** - * TemerityLibrary is the main entry point for the Temerity library. - * - * This object initializes the Koin dependency injection framework and returns a library or builder instance - * - */ -public object TemerityLibrary { - public val DEFAULT_WEB_TIMEOUT: Duration = 2.minutes - - private val koinApp = koinApplication { - modules( - loggerModule(), - platformModule(), - libModule, - ) - } - internal val koin: Koin = koinApp.koin -} - -/** - * Create a Temerity instance using Kotlin-DSL. - * Adapted from the tmdb-api project: https://github.com/MoviebaseApp/tmdb-kotlin/raw/refs/heads/main/tmdb-api/src/commonMain/kotlin/app/moviebase/tmdb/Tmdb4.kt - */ -@TemDsl -public fun Temerity(block: TemClientConfig.() -> Unit): Temerity { - val config = TemClientConfig().apply(block) - return Temerity(config) -} diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/core/LoggerFactory.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/core/LoggerFactory.kt new file mode 100644 index 0000000000000000000000000000000000000000..eae0e55d1ac6f140e52c3baaf6a6a7d94766233a --- /dev/null +++ b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/core/LoggerFactory.kt @@ -0,0 +1,27 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; version 2.1 of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package edu.ucsc.its.temerity.core + +import co.touchlab.kermit.Logger +import edu.ucsc.its.temerity.TemClientConfig + +internal object LoggerFactory { + internal fun createLogger(tag: String?, config: TemClientConfig? = null, supportKtxNotebook: Boolean = false) = createCommonLogger(tag = tag, config = config, supportKtxNotebook = supportKtxNotebook) +} + +internal expect fun createCommonLogger(tag: String?, config: TemClientConfig? = null, supportKtxNotebook: Boolean = false): Logger diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/core/Temerity.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/core/Temerity.kt index 896c0ad21a9ee2768b1214459305d7c4a57de4d2..ab73b7c140a8d20f60ad72aac32e60db4ae0759e 100644 --- a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/core/Temerity.kt +++ b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/core/Temerity.kt @@ -17,27 +17,33 @@ */ package edu.ucsc.its.temerity.core +import co.touchlab.stately.collections.ConcurrentMutableMap import com.skydoves.sandwich.ApiResponse -import com.skydoves.sandwich.StatusCode +import com.skydoves.sandwich.StatusCode.NoContent import com.skydoves.sandwich.getOrThrow import com.skydoves.sandwich.ktor.executeApiResponse import com.skydoves.sandwich.ktor.statusCode import com.skydoves.sandwich.onSuccess import edu.ucsc.its.temerity.AuditLogSortOrder +import edu.ucsc.its.temerity.AuditLogSortOrder.NEW_FIRST +import edu.ucsc.its.temerity.BuildConfig import edu.ucsc.its.temerity.TemClientConfig import edu.ucsc.its.temerity.TemerityApi import edu.ucsc.its.temerity.api.PlatformApi -import edu.ucsc.its.temerity.core.JsonFactory.buildJson -import edu.ucsc.its.temerity.core.TemerityLibrary.koin -import edu.ucsc.its.temerity.createJobScope -import edu.ucsc.its.temerity.extensions.applyAuditLogFormat -import edu.ucsc.its.temerity.extensions.applyScheduledSessionDateFormat +import edu.ucsc.its.temerity.core.Temerity.Companion.DEFAULT_WEB_TIMEOUT +import edu.ucsc.its.temerity.di.LibModule.libModule +import edu.ucsc.its.temerity.di.platformModule +import edu.ucsc.its.temerity.extensions.coroutines.availableThreads +import edu.ucsc.its.temerity.extensions.coroutines.createJobScope +import edu.ucsc.its.temerity.extensions.datetime.DateTimeExt +import edu.ucsc.its.temerity.extensions.time.applyAuditLogFormat +import edu.ucsc.its.temerity.extensions.time.applyScheduledSessionDateFormat import edu.ucsc.its.temerity.model.AuditLogEntry import edu.ucsc.its.temerity.model.Course import edu.ucsc.its.temerity.model.Device import edu.ucsc.its.temerity.model.DeviceRecordingSession import edu.ucsc.its.temerity.model.EventType -import edu.ucsc.its.temerity.model.FolderPermissions +import edu.ucsc.its.temerity.model.FolderPermissions.Action import edu.ucsc.its.temerity.model.Group import edu.ucsc.its.temerity.model.NewUser import edu.ucsc.its.temerity.model.User @@ -45,40 +51,147 @@ import edu.ucsc.its.temerity.model.UserGroup import edu.ucsc.its.temerity.model.UserRecordingSession import edu.ucsc.its.temerity.model.UserUpdate import edu.ucsc.its.temerity.sortByCreationDate -import io.github.reactivecircus.cache4k.Cache +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.LogLevel.ALL +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.header import io.ktor.client.statement.HttpResponse +import io.ktor.http.URLProtocol import io.ktor.util.cio.toByteArray import io.ktor.utils.io.ByteReadChannel -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime import kotlinx.datetime.minus import kotlinx.serialization.SerializationException -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import org.koin.core.context.GlobalContext.get +import org.koin.core.Koin +import org.koin.core.KoinApplication +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.logger.Level +import org.koin.core.logger.Level.DEBUG +import org.koin.core.logger.Level.ERROR +import org.koin.core.logger.Level.INFO +import org.koin.core.logger.Level.NONE +import org.koin.core.logger.Level.WARNING +import org.koin.core.logger.Logger +import org.koin.core.logger.MESSAGE import org.koin.core.parameter.parametersOf +import org.koin.core.qualifier.named +import org.koin.dsl.koinApplication +import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes +import kotlin.time.DurationUnit.MILLISECONDS import co.touchlab.kermit.Logger as KermitLogger +import io.ktor.client.plugins.logging.Logger as KtorLogger /* * Adapted from https://github.com/santimattius/kmp-networking/blob/main/README.md */ public class Temerity internal constructor( private val config: TemClientConfig, -) : TemerityApi { - +) : TemerityApi, + KoinComponent { /** * Constructor for Temerity + * @param temApiToken The [String] API token to use for the Temerity client */ internal constructor(temApiToken: String) : this(TemClientConfig.withToken(temApiToken)) - private val json: Json = buildJson() - private val platformApi: PlatformApi - private val cachedUserRoleList: Cache<Int, String> + public companion object { + @JvmStatic + public val DEFAULT_WEB_TIMEOUT: Duration = 2.minutes + + // TODO: Use this as cache4k expiration time + @JvmStatic + public val DEFAULT_CACHE_EXPIRATION: Duration = 15.minutes + + @JvmStatic + internal val DEFAULT_MINIMUM_THREAD_COUNT: Int = 2 + + @JvmStatic + internal fun createLogger( + tag: String? = null, + config: TemClientConfig? = null, + supportKtxNotebook: Boolean = false, + ): KermitLogger = LoggerFactory.createLogger( + tag = tag, + config = config, + supportKtxNotebook = supportKtxNotebook, + ) + + @JvmStatic + @Suppress("MemberVisibilityCanBePrivate") + public fun currentDate(): LocalDate = DateTimeExt.currentDate() + + @JvmStatic + @Suppress("MemberVisibilityCanBePrivate") + public fun currentTime(): LocalTime = DateTimeExt.currentTime() + } + + /** + * This function initializes the Koin dependency injection framework, + * which provides the library's [HttpClient] and [PlatformApi] instances + * via factories defined as part of the [libModule]. + */ + private fun createKoinApp(config: TemClientConfig) = object : TemerityKoinContext() { + override val logger = createCommonLogger(tag = BuildConfig.PACKAGE_NAME, config) + override val koinApp = koinApplication { + logger(object : Logger() { + override fun display(level: Level, msg: MESSAGE) { + when (level) { + DEBUG -> logger.d(msg) + INFO -> logger.i(msg) + ERROR -> logger.e(msg) + NONE -> logger.v(msg) + WARNING -> logger.w(msg) + } + } + }) + modules( + libModule(), + platformModule(), + ) + } + override val koin: Koin = koinApp.koin + } + + internal abstract class TemerityKoinContext { + abstract val koinApp: KoinApplication + abstract val koin: Koin + abstract val logger: KermitLogger + } + + override val version: String = BuildConfig.LIB_VERSION + + /** + * Kotlinx-serialization [Json] encoder/decoder object used for serializing/deserializing JSON object responses + */ + private val json: Json = Json { + coerceInputValues = true + } + internal val koinContext = createKoinApp(config) + override fun getKoin(): Koin = koinContext.koin + + private var libraryLogger: KermitLogger + private var platformApi: PlatformApi + private var cachedUserRoleList: ConcurrentMutableMap<Int, String> + private var libraryCoroutineDispatcher: CoroutineDispatcher + private var libraryCoroutineScope: CoroutineScope + private var jsonProcessingDispatcher: CoroutineDispatcher + private var webRequestDispatcher: CoroutineDispatcher + private var fileProcessDispatcher: CoroutineDispatcher init { check(!config.serviceToken.isNullOrBlank()) { @@ -89,13 +202,26 @@ public class Temerity internal constructor( "Service url must be provided. Set it using TemClientConfig.serviceUrl(url)" } - platformApi = koin.get<PlatformApi> { - parametersOf(config) + libraryCoroutineDispatcher = get<CoroutineDispatcher>(named("libraryCoroutineDispatcher")) { + parametersOf(availableThreads(config.optThreadCount), "Temerity Library Dispatcher") + } + libraryCoroutineScope = get<CoroutineScope>(named("libraryCoroutineScope")) { + parametersOf(libraryCoroutineDispatcher) } - cachedUserRoleList = Cache.Builder<Int, String>() - .expireAfterWrite(config.cacheTimeout ?: 15.minutes) - .build() + jsonProcessingDispatcher = get<CoroutineDispatcher>(named("childDispatcher")) { parametersOf(libraryCoroutineDispatcher, 2, "TemerityJsonProcessingCoroutineDispatcher") } + webRequestDispatcher = get<CoroutineDispatcher>(named("childDispatcher")) { parametersOf(libraryCoroutineDispatcher, 2, "TemerityWebRequestCoroutineDispatcher") } + fileProcessDispatcher = get<CoroutineDispatcher>(named("childDispatcher")) { parametersOf(libraryCoroutineDispatcher, 1, "TemerityFileProcessCoroutineDispatcher") } + + libraryLogger = get<KermitLogger>(named("libraryLogger")) { + parametersOf(BuildConfig.PACKAGE_NAME, config) + } + + platformApi = get<PlatformApi>(named("ktorfitApi")) { + parametersOf(config, webRequestDispatcher) + } + + cachedUserRoleList = ConcurrentMutableMap() } /** @@ -114,15 +240,16 @@ public class Temerity internal constructor( // If the API returns a 204 No Content status code, we should throw a BreakException to stop the page iteration loop // As of Cashew release, this is only used in getGroups() to stop the loop when no more groups are returned // May be used in the future for other paginated endpoints (such as getAuditLogEntries) - StatusCode.NoContent -> throw BreakException(page - 1) + NoContent -> throw BreakException(page - 1) else -> Unit } } val apiResponsePayload = response.getOrThrow() + if (apiResponsePayload == "[]") throw BreakException(page - 1) // Handle deserialization exceptions. Queue up decode: - val deserializedJson = runCatching { + val deserializedJson = createJobScope(jsonProcessingDispatcher).runCatching { json.decodeFromString<T>(apiResponsePayload) } // Attempt, returning the deserialized object if successful @@ -130,8 +257,12 @@ public class Temerity internal constructor( when (exception) { is SerializationException -> { // TODO: Implement platform API version checking - get().get<KermitLogger>().d { "Returned JSON object: ${exception.message}" } - error("Encountered error decoding response from platform API. You likely need to choose a Temerity release that supports the API version implemented by your instance. \nPlatform API response data : $apiResponsePayload") + get<KermitLogger>(named("libraryLogger")).d { "Returned JSON object: ${exception.message}" } + val error = "Encountered error decoding response from platform API. You likely need to choose a Temerity release that supports the API version implemented by your instance. \nPlatform API response data : $apiResponsePayload" + error.run { + get<KermitLogger>(named("libraryLogger")).e { this } + error(this) + } } else -> throw exception @@ -140,61 +271,95 @@ public class Temerity internal constructor( } public override suspend fun getUsers(): List<User> = - decodeResponseCatching(platformApi.getUsers().executeApiResponse<String>()) + withContext(libraryCoroutineDispatcher) { + val userRequest = platformApi.getUsers() + val response = userRequest.executeApiResponse<String>() + decodeResponseCatching(response) + } public override suspend fun getUser(userId: Long): User = - getUsers().first { it.userId == userId } - - public override suspend fun createUser(primaryIdentifier: String, newUser: NewUser): HttpResponse { - val validationMessage = newUser.validate() - if (validationMessage != "Valid") { - error(validationMessage) - } else { - return platformApi.createUser(primaryIdentifier, json.encodeToString(newUser)).getOrThrow() + withContext(libraryCoroutineDispatcher) { + val returnedUsers = getUsers() + returnedUsers.first { it.userId == userId } + } + + public override suspend fun createUser(primaryIdentifier: String, newUser: NewUser): HttpResponse = + withContext(libraryCoroutineDispatcher) { + val validationMessage = newUser.validate() + if (validationMessage != "Valid") { + error(validationMessage) + } else { + val serializedNewUser = createJobScope(jsonProcessingDispatcher).run { + json.encodeToString(newUser) + } + val request = platformApi.createUser(primaryIdentifier, serializedNewUser) + request.getOrThrow() + } } - } public override suspend fun updateUser(userId: Long, userUpdate: UserUpdate): HttpResponse = - platformApi.setUser(userId, json.encodeToString(userUpdate)).getOrThrow() + withContext(libraryCoroutineDispatcher) { + val serializedUpdatedUser = createJobScope(jsonProcessingDispatcher).run { + json.encodeToString(userUpdate) + } + val request = platformApi.setUser(userId, serializedUpdatedUser) + request.getOrThrow() + } public override suspend fun deleteUser(userId: Long): HttpResponse = - platformApi.deleteUser(userId).getOrThrow() - - public override suspend fun refreshCachedUserRoles(): List<String> { - val returnedUsersResponse = platformApi.getUsers().executeApiResponse<String>().getOrThrow() - val returnedUserList = json.decodeFromString<List<User>>(returnedUsersResponse) - val roleTypes = returnedUserList.map { it.userType }.distinct() - roleTypes.forEach { - cachedUserRoleList.put(it.hashCode(), it) + withContext(libraryCoroutineDispatcher) { + val request = platformApi.deleteUser(userId) + request.getOrThrow() } - return roleTypes - } - public override suspend fun getCachedUserRoles(refresh: Boolean): List<String> { - if (refresh) { - return refreshCachedUserRoles() + public override suspend fun refreshCachedUserRoles(): List<String> = + withContext(libraryCoroutineDispatcher) { + val usersRequest = platformApi.getUsers() + val returnedUsersResponse = usersRequest.executeApiResponse<String>().getOrThrow() + val returnedUserList = createJobScope(jsonProcessingDispatcher).run { + json.decodeFromString<List<User>>(returnedUsersResponse) + } + val roleTypes = returnedUserList.map { it.userType }.distinct() + cachedUserRoleList.clear() + roleTypes.forEach { + cachedUserRoleList[it.hashCode()] = it + } + roleTypes } - val returnedUserRoles = cachedUserRoleList.asMap().values.toList() - return returnedUserRoles.ifEmpty { - refreshCachedUserRoles() + + public override suspend fun getCachedUserRoles(refresh: Boolean): List<String> = + withContext(libraryCoroutineDispatcher) { + if (refresh) { + refreshCachedUserRoles() + } + val returnedUserRoles = cachedUserRoleList.values.toList() + returnedUserRoles.ifEmpty { + refreshCachedUserRoles() + } } - } public override suspend fun getUserGroups(userId: Long): List<UserGroup> = - decodeResponseCatching(platformApi.getUserGroups(userId).executeApiResponse<String>()) + withContext(libraryCoroutineDispatcher) { + val request = platformApi.getUserGroups(userId) + val response = request.executeApiResponse<String>() + decodeResponseCatching(response) + } - public override suspend fun getUserGroupsOwned(userId: Long): List<UserGroup> = - decodeResponseCatching<List<UserGroup>>(platformApi.getUserGroupsOwned(userId).executeApiResponse()) + public override suspend fun getUserGroupsOwned(userId: Long): List<UserGroup> = withContext(libraryCoroutineDispatcher) { + val request = platformApi.getUserGroupsOwned(userId) + val response = request.executeApiResponse<String>() + decodeResponseCatching<List<UserGroup>>(response) + } public override suspend fun createUserWithExternalId(primaryIdentifier: String, newUser: NewUser): HttpResponse { TODO("Not yet implemented") } - public override suspend fun addFolderPermission(perms: FolderPermissions.Action): HttpResponse { + public override suspend fun addFolderPermission(perms: Action): HttpResponse { TODO("Not yet implemented") } - public override suspend fun removeFolderPermission(perms: FolderPermissions.Action) { + public override suspend fun removeFolderPermission(perms: Action) { TODO("Not yet implemented") } @@ -202,41 +367,44 @@ public class Temerity internal constructor( TODO("Not yet implemented") } - public override suspend fun getGroups(paginated: Boolean): List<Group> { + public override suspend fun getGroups(paginated: Boolean): List<Group> = withContext(libraryCoroutineDispatcher) { when (paginated) { true -> { val returnedGroups = ArrayList<Group>() try { for (x in (0..Int.MAX_VALUE)) { - val thisPage = platformApi.getGroupPage(x, true) - val thisPageGroups: List<Group> = decodeResponseCatching(thisPage.executeApiResponse<String>(), page = x) + val thisPageRequest = platformApi.getGroupPage(x, true) + val response = thisPageRequest.executeApiResponse<String>() + val thisPageGroups: List<Group> = decodeResponseCatching(response, page = x) returnedGroups.addAll(thisPageGroups) - delay(750) } } catch (e: Exception) { when (e) { is BreakException -> { if (config.optDebugEnabled) { - get().get<KermitLogger>().d("Caught BreakException() from decodeResponseCatching() notifying we're done reading groups from API: ${e.page} pages read") + get<KermitLogger>(named("libraryLogger")).d("Caught BreakException() from decodeResponseCatching() notifying we're done reading groups from API: ${e.page} pages read") } - return returnedGroups + return@withContext returnedGroups } else -> throw e } } - return returnedGroups + returnedGroups } + else -> { - return emptyList() + emptyList() } } } - public override suspend fun getCourse(courseCode: String): Course = decodeResponseCatching(platformApi.getCourse(courseCode).executeApiResponse<String>()) + public override suspend fun getCourse(courseCode: String): Course = withContext(libraryCoroutineDispatcher) { + decodeResponseCatching(platformApi.getCourse(courseCode).executeApiResponse<String>()) + } - public override suspend fun getCourses(paginated: Boolean): List<Course> { - return when (paginated) { + public override suspend fun getCourses(paginated: Boolean): List<Course> = withContext(libraryCoroutineDispatcher) { + when (paginated) { true -> { val returnedCourses = ArrayList<Course>() try { @@ -251,15 +419,15 @@ public class Temerity internal constructor( when (e) { is BreakException -> { if (config.optDebugEnabled) { - get().get<KermitLogger>().d("Caught BreakException() from decodeResponseCatching() notifying we're done reading groups from API: $e") + get<KermitLogger>(named("libraryLogger")).d("Caught BreakException() from decodeResponseCatching() notifying we're done reading groups from API: $e") } - return returnedCourses + return@withContext returnedCourses } else -> throw e } } - return returnedCourses + returnedCourses } false -> { @@ -276,10 +444,10 @@ public class Temerity internal constructor( endTime: LocalDate, eventTypeList: List<EventType>?, sortOrder: AuditLogSortOrder?, - ): List<AuditLogEntry> { + ): List<AuditLogEntry> = withContext(libraryCoroutineDispatcher) { // TODO: Error out if user inputs time window > 1 month, since the platform API doesn't support val returnedEntries = ArrayList<AuditLogEntry>() - val jobScope = createJobScope(Dispatchers.IO) + val jobScope = createJobScope(libraryCoroutineScope.coroutineContext) val requestJobs = with(eventTypeList ?: EventType.entries) { map { eventType -> jobScope.async { @@ -292,8 +460,8 @@ public class Temerity internal constructor( results.forEach { returnedEntries.addAll(it) } // Apply passed sortOrder, or default to NEW_FIRST TODO: Read from user settings - returnedEntries.applyOrDefault(sortOrder ?: AuditLogSortOrder.NEW_FIRST) - return returnedEntries + returnedEntries.applyOrDefault(sortOrder ?: NEW_FIRST) + returnedEntries } // Get audit log entries for a specific event type within window @@ -304,17 +472,17 @@ public class Temerity internal constructor( eventType: EventType, sortOrder: AuditLogSortOrder?, paginated: Boolean, - ): List<AuditLogEntry> { + ): List<AuditLogEntry> = withContext(libraryCoroutineDispatcher) { require(startTime <= endTime) { "End date must be same or later than start date" } when (paginated) { true -> { val returnedEntries = ArrayList<AuditLogEntry>() - var windowStart = if (endTime.minus(1, DateTimeUnit.Companion.MONTH) < startTime) { + var windowStart = if (endTime.minus(1, DateTimeUnit.MONTH) < startTime) { startTime } else { - endTime.minus(1, DateTimeUnit.Companion.MONTH) + endTime.minus(1, DateTimeUnit.MONTH) } var windowEnd = endTime while (windowStart < windowEnd) { @@ -326,14 +494,18 @@ public class Temerity internal constructor( entryOffset = x * 75, eventType = eventType.value, ) - val logEventEntries: List<AuditLogEntry> = decodeResponseCatching(endpointRequest.executeApiResponse<String>(), page = x) + val response = createJobScope(webRequestDispatcher).run { + endpointRequest.executeApiResponse<String>() + } + val logEventEntries: List<AuditLogEntry> = + decodeResponseCatching(response, page = x) returnedEntries.addAll(logEventEntries) } } catch (e: Exception) { when (e) { is BreakException -> { if (config.optDebugEnabled) { - get().get<KermitLogger>().d("Caught BreakException() from decodeResponseCatching() notifying we're done reading audit log entries from API for specific window: ${e.page} pages read") + get<KermitLogger>(named("libraryLogger")).d("Caught BreakException() from decodeResponseCatching() notifying we're done reading audit log entries from API for specific window: ${e.page} pages read") } break } @@ -342,64 +514,167 @@ public class Temerity internal constructor( } } windowEnd = windowStart - val backStep = windowStart.minus(1, DateTimeUnit.Companion.MONTH) + val backStep = windowStart.minus(1, DateTimeUnit.MONTH) windowStart = if (backStep < startTime) startTime else backStep } // Apply passed sortOrder, or default to NEW_FIRST TODO: Read from user settings - returnedEntries.applyOrDefault(sortOrder ?: AuditLogSortOrder.NEW_FIRST) - return returnedEntries + returnedEntries.applyOrDefault(sortOrder ?: NEW_FIRST) + returnedEntries } + false -> { TODO("Implement non-paginated audit log entry retrieval") } } } - private fun ArrayList<AuditLogEntry>.applyOrDefault(sortOrder: AuditLogSortOrder?): ArrayList<AuditLogEntry> = when (sortOrder) { - null -> { - // Currently reads from prefs, falling back to BuildConfig constant. Can be overridden by passing a sort order as a parameter. - // TODO: Implement a way to pass a clientDefaultSortOrder to Builder - // TODO: Persist user-defined default sort order for subsequent runs - apply { sortByCreationDate(AuditLogSortOrder.NEW_FIRST) } - } + private suspend fun ArrayList<AuditLogEntry>.applyOrDefault(sortOrder: AuditLogSortOrder?): ArrayList<AuditLogEntry> = withContext(libraryCoroutineDispatcher) { + with(this@applyOrDefault) { + when (sortOrder) { + null -> { + // Currently reads from prefs, falling back to BuildConfig constant. Can be overridden by passing a sort order as a parameter. + // TODO: Implement a way to pass a clientDefaultSortOrder to Builder + // TODO: Persist user-defined default sort order for subsequent runs + apply { sortByCreationDate(NEW_FIRST) } + } - else -> apply { sortByCreationDate(sortOrder) } + else -> apply { sortByCreationDate(sortOrder) } + } + } } - // TODO: Implement setUser() function - - public override suspend fun getDevices(): List<Device> = - decodeResponseCatching(platformApi.getDevices().executeApiResponse<String>()) + public override suspend fun getDevices(): List<Device> = withContext(libraryCoroutineDispatcher) { + val request = platformApi.getDevices() + val response = request.executeApiResponse<String>() + decodeResponseCatching(response) + } - public override suspend fun getDevice(deviceId: Long): Device = - decodeResponseCatching(platformApi.getDeviceById(deviceId).executeApiResponse<String>()) + public override suspend fun getDevice(deviceId: Long): Device = withContext(libraryCoroutineDispatcher) { + val request = platformApi.getDeviceById(deviceId) + val response = request.executeApiResponse<String>() + decodeResponseCatching(response) + } public override suspend fun getUserSessions( userId: String, startTime: LocalDate, endTime: LocalDate, - ): List<UserRecordingSession> = - decodeResponseCatching( - platformApi.getUserSessions( - userId, - startTime.applyScheduledSessionDateFormat(), - endTime.applyScheduledSessionDateFormat(), - ).executeApiResponse<String>(), + ): List<UserRecordingSession> = withContext(libraryCoroutineDispatcher) { + val request = platformApi.getUserSessions( + userId, + startTime.applyScheduledSessionDateFormat(), + endTime.applyScheduledSessionDateFormat(), ) + val response = request.executeApiResponse<String>() + decodeResponseCatching(response) + } public override suspend fun getDeviceSchedule( deviceId: Long, startTime: LocalDate, endTime: LocalDate, - ): List<DeviceRecordingSession> = - decodeResponseCatching( - platformApi.getDeviceSchedule( - deviceId, - startTime.applyScheduledSessionDateFormat(), - endTime.applyScheduledSessionDateFormat(), - ).executeApiResponse<String>(), + ): List<DeviceRecordingSession> = withContext(libraryCoroutineDispatcher) { + val request = platformApi.getDeviceSchedule( + deviceId, + startTime.applyScheduledSessionDateFormat(), + endTime.applyScheduledSessionDateFormat(), ) + val response = request.executeApiResponse<String>() + decodeResponseCatching(response) + } - public override suspend fun getStorageAnalyticsReport(groupId: Long): ByteArray = - platformApi.getStorageAnalyticsReport(groupId).executeApiResponse<ByteReadChannel>().getOrThrow().toByteArray(limit = 2000000000) // Limit file downloads to 2 GB + public override suspend fun getStorageAnalyticsReport(groupId: Long): ByteArray = withContext(libraryCoroutineDispatcher) { + val apiRequest = platformApi.getStorageAnalyticsReport(groupId) + val responseBytes = apiRequest.executeApiResponse<ByteReadChannel>().getOrThrow() + withContext(fileProcessDispatcher) { + responseBytes.toByteArray(limit = 2000000000) + } // Limit file downloads to 2 GB + } } + +/** + * Adapted from https://github.com/joreilly/FantasyPremierLeague/ + * Adapted from the tmdb-api project: https://github.com/MoviebaseApp/tmdb-kotlin/raw/refs/heads/main/tmdb-api/src/commonMain/kotlin/app/moviebase/tmdb/core/HttpClientFactory.kt + * This function creates an HttpClient with the provided HttpClientEngine. + * The HttpClientEngine is injected by the Koin platformModule(), which as its name suggests provides an HttpClientEngine implementation that is platform-dependent. + * It configures the client to log all requests and responses if enableNetworkLogs is true. TODO: read this as a build setting + * It also sets a default request header with the provided authToken. + * @param httpClientEngine The HttpClientEngine to use for the HttpClient. + * @param config A block specifying the following options: + * - Whether to log all requests and responses. + * - Service URL to make requests to. + * - Token to set as a default request header. + * @return The created HttpClient. + */ +internal fun buildHttpClient( + httpClientEngine: HttpClientEngine, + config: TemClientConfig, + logger: KermitLogger, +): HttpClient { + val defaultConfig: HttpClientConfig<*>.() -> Unit = { + // Can't use install(ContentNegotiation){ json() } here because YuJa's API returns a 406 error if the Accept header is set to application/json + // This requires JSON transformations to be done via manually-called library functions + + // Pass the stored authToken as a header in every request + defaultRequest { + header("authToken", config.serviceToken) + url { + protocol = URLProtocol.HTTPS + } + } + + if (config.optDebugEnabled) { + when (config.optHttpClientLoggingBlock) { + null -> { + install(Logging) { + this.logger = object : KtorLogger { + override fun log(message: String) { + logger.d(message) + } + } + level = ALL + sanitizeHeader { headerKey -> + headerKey == "authToken" + } + } + } + else -> { + config.optHttpClientLoggingBlock?.let { + Logging(it) + } + } + } + } + + expectSuccess = config.optExpectSuccess + + if (config.optUseWebTimeout) { + val defaultWebTimeout = DEFAULT_WEB_TIMEOUT.toLong(MILLISECONDS) + val configuredWebTimeout = config.optWebTimeoutDuration?.toLong(MILLISECONDS) + install(HttpTimeout) { + connectTimeoutMillis = configuredWebTimeout ?: defaultWebTimeout + requestTimeoutMillis = configuredWebTimeout ?: defaultWebTimeout + socketTimeoutMillis = configuredWebTimeout ?: defaultWebTimeout + } + } + + config.optHttpClientConfigBlock?.invoke(this) + } + return config.optHttpClientBuilder?.invoke()?.config(defaultConfig) ?: HttpClient(httpClientEngine, defaultConfig) +} + +/** + * Create a Temerity instance using Kotlin-DSL. + */ +@TemDsl +public fun Temerity(block: TemClientConfig.() -> Unit): Temerity { + // Adapted from the tmdb-api project: https://github.com/MoviebaseApp/tmdb-kotlin/raw/refs/heads/main/tmdb-api/src/commonMain/kotlin/app/moviebase/tmdb/Tmdb4.kt + val config = TemClientConfig().apply(block) + return Temerity(config) +} + +/** + * Create a Temerity Client Configuration instance using Kotlin-DSL. + */ +@TemDsl +public fun TemClientConfig(block: TemClientConfig.() -> Unit): TemClientConfig = TemClientConfig().apply(block) diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/di/Koin.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/di/Koin.kt deleted file mode 100644 index 92400c898f7ac651fe51ceb7556436fece7e64e2..0000000000000000000000000000000000000000 --- a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/di/Koin.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) - * Copyright 2022-2024 The Regents of the University of California. All rights reserved. - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; version 2.1 of the License. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ -package edu.ucsc.its.temerity.di - -import co.touchlab.kermit.NoTagFormatter -import co.touchlab.kermit.Severity -import co.touchlab.kermit.loggerConfigInit -import co.touchlab.kermit.platformLogWriter -import com.skydoves.sandwich.ktorfit.ApiResponseConverterFactory -import de.jensklingenberg.ktorfit.ktorfit -import edu.ucsc.its.temerity.TemClientConfig -import edu.ucsc.its.temerity.api.PlatformApi -import edu.ucsc.its.temerity.api.createPlatformApi -import edu.ucsc.its.temerity.core.HttpClientFactory.buildHttpClient -import edu.ucsc.its.temerity.core.TemerityLibrary.koin -import io.ktor.client.HttpClient -import org.koin.core.Koin -import org.koin.core.component.KoinComponent -import org.koin.core.parameter.parametersOf -import org.koin.dsl.module -import co.touchlab.kermit.Logger as KermitLogger - -internal fun loggerModule() = module { - factory<co.touchlab.kermit.Logger> { tag -> - createLogger(tag.getOrNull()) - } -} - -/** - * This function provides the Koin module for the Temerity library. - * It includes the platform module and provides factories for [HttpClient] and [PlatformApi]. - */ -internal val libModule = module { - factory { (config: TemClientConfig) -> - buildHttpClient( - httpClientEngine = get(), - config = config, - ) - } - factory { (config: TemClientConfig) -> - val client: HttpClient = get { parametersOf(config) } - val ktorfit = ktorfit { - config.serviceUrl?.let { baseUrl(it) } - httpClient(client) - converterFactories(ApiResponseConverterFactory.create()) - } - ktorfit.createPlatformApi() - } -} - -private fun createLogger(tag: String?): KermitLogger = KermitLogger( - config = loggerConfigInit( - platformLogWriter(NoTagFormatter), - minSeverity = Severity.Debug, - ), - tag = tag ?: "TemerityLib", -) - -internal abstract class TemerityKoinComponent : KoinComponent { - override fun getKoin(): Koin = koin -} diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/di/LibModule.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/di/LibModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..d6fbe81f556afa013b7acc10362d538977c9b6a3 --- /dev/null +++ b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/di/LibModule.kt @@ -0,0 +1,75 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; version 2.1 of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package edu.ucsc.its.temerity.di + +import com.skydoves.sandwich.ktorfit.ApiResponseConverterFactory +import de.jensklingenberg.ktorfit.ktorfit +import edu.ucsc.its.temerity.TemClientConfig +import edu.ucsc.its.temerity.api.PlatformApi +import edu.ucsc.its.temerity.api.createPlatformApi +import edu.ucsc.its.temerity.core.Temerity.Companion.createLogger +import edu.ucsc.its.temerity.core.buildHttpClient +import edu.ucsc.its.temerity.extensions.coroutines.createLibraryScope +import io.ktor.client.HttpClient +import io.ktor.client.engine.HttpClientEngine +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import org.koin.core.parameter.parametersOf +import org.koin.core.qualifier.named +import org.koin.dsl.module +import co.touchlab.kermit.Logger as KermitLogger + +internal object LibModule { + /** + * This function provides the Koin module for the Temerity library. + * It includes the platform module and provides factories for [HttpClient] and [PlatformApi]. + */ + internal fun libModule() = module { + single<KermitLogger>(named("libraryLogger")) { (tag: String, config: TemClientConfig) -> + createLogger(tag, config) + } + single<CoroutineDispatcher>(named("libraryCoroutineDispatcher")) { (threadCount: Int, dispatcherName: String) -> + Dispatchers.IO.limitedParallelism(threadCount, dispatcherName) + } + factory<CoroutineDispatcher>(named("childDispatcher")) { (parentDispatcher: CoroutineDispatcher, threadCount: Int, dispatcherName: String) -> + parentDispatcher.limitedParallelism(threadCount, dispatcherName) + } + single<CoroutineScope>(named("libraryCoroutineScope")) { (dispatcher: CoroutineDispatcher) -> + createLibraryScope(dispatcher) + } + factory<HttpClient>(named("httpClient")) { (dispatcher: CoroutineDispatcher, config: TemClientConfig, kermit: co.touchlab.kermit.Logger) -> + buildHttpClient( + httpClientEngine = get<HttpClientEngine>(named("httpClientEngine")) { parametersOf(dispatcher) }, + config = config, + logger = kermit, + ) + } + factory<PlatformApi>(named("ktorfitApi")) { (config: TemClientConfig, webRequestDispatcher: CoroutineDispatcher) -> + val logger: KermitLogger = get<KermitLogger>(named("libraryLogger")) + val client: HttpClient = + get<HttpClient>(named("httpClient")) { parametersOf(webRequestDispatcher, config, logger) } + val ktorfit = ktorfit { + config.serviceUrl?.let { baseUrl(it) } + httpClient(client) + converterFactories(ApiResponseConverterFactory.create()) + } + ktorfit.createPlatformApi() + } + } +} diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/Expect.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/di/PlatformModule.kt similarity index 86% rename from temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/Expect.kt rename to temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/di/PlatformModule.kt index 41bdf96fdd09c08c129c55fc1e91f240a0089258..06086bdd73c13f50739d89aafbd5708313d113ee 100644 --- a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/Expect.kt +++ b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/di/PlatformModule.kt @@ -15,13 +15,13 @@ * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ -package edu.ucsc.its.temerity +package edu.ucsc.its.temerity.di import org.koin.core.module.Module /** - * This function provides an expectation for the implementation of a platform-specific dependency module + * This function provides an implementation of a platform-specific dependency module * Implementations for each source set contain factories for injected platform-specific dependencies that the Temerity library relies on. - * For now, this includes an HttpClientEngine for making requests, and a PreferencesSettings object for storing settings. + * For now, this includes an HttpClientEngine for making requests */ internal expect fun platformModule(): Module diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/coroutines/CoroutinesExt.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/coroutines/CoroutinesExt.kt new file mode 100644 index 0000000000000000000000000000000000000000..402405c011efd66aaa337b484d9bae9dd9f6594a --- /dev/null +++ b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/coroutines/CoroutinesExt.kt @@ -0,0 +1,55 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; version 2.1 of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package edu.ucsc.its.temerity.extensions.coroutines + +import edu.ucsc.its.temerity.core.Temerity.Companion.DEFAULT_MINIMUM_THREAD_COUNT +import io.ktor.util.SilentSupervisor +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.CoroutineContext + +internal fun createJobScope(coroutineContext: CoroutineContext, allowIndependentFailure: Boolean = false): CoroutineScope { + val parentJob = if (allowIndependentFailure) SupervisorJob() else Job() + return CoroutineScope(coroutineContext + parentJob) +} + +internal fun createLibraryScope(dispatcher: CoroutineDispatcher): CoroutineScope = CoroutineScope(dispatcher + SilentSupervisor()) + +internal fun availableThreads(maximumThreadCount: Int? = null): Int { + val availableThreadCount = Runtime.getRuntime().availableProcessors().plus(1) // +1 to maintain full utilization in case one coroutine blocks. Never less than 2 (DEFAULT_MINIMUM_THREAD_COUNT). See: https://github.com/Kotlin/kotlinx.coroutines/issues/261 + with(maximumThreadCount) { + return when { + this == null -> availableThreadCount + this > availableThreadCount -> { + // TODO: Log warning that the configured thread count is higher than the number of available threads + availableThreadCount + } + this < DEFAULT_MINIMUM_THREAD_COUNT -> { + // TODO: Log warning that the configured thread count is lower than the default minimum thread pool size + DEFAULT_MINIMUM_THREAD_COUNT + } + this < availableThreadCount -> { + // TODO: Log warning that the configured thread count is lower than the optimal thread pool size + this + } + else -> availableThreadCount + } + } +} diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/Util.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/datetime/DateTimeExt.kt similarity index 65% rename from temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/Util.kt rename to temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/datetime/DateTimeExt.kt index 157f53f1a2dd182e0b04c6f8418679fc667dd3c0..04c295361d81e270b0325b9d5ddfbbc9afd02286 100644 --- a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/Util.kt +++ b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/datetime/DateTimeExt.kt @@ -15,25 +15,21 @@ * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ -package edu.ucsc.its.temerity +package edu.ucsc.its.temerity.extensions.datetime -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.datetime.Clock import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.todayIn -import kotlin.coroutines.CoroutineContext -internal fun currentTz(): TimeZone = TimeZone.currentSystemDefault() +internal object DateTimeExt { + internal fun currentTz(): TimeZone = TimeZone.currentSystemDefault() -internal fun thisInstant() = Clock.System.now() + internal fun thisInstant() = Clock.System.now() -internal fun currentDate(): LocalDate = Clock.System.todayIn(currentTz()) + internal fun currentDate(): LocalDate = Clock.System.todayIn(currentTz()) -internal fun currentTime(): LocalTime = thisInstant().toLocalDateTime(currentTz()).time - -internal fun createJobScope(coroutineContext: CoroutineContext = Dispatchers.Main): CoroutineScope = CoroutineScope(coroutineContext + Job()) + internal fun currentTime(): LocalTime = thisInstant().toLocalDateTime(currentTz()).time +} diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/log/FilesystemLogWriter.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/log/FilesystemLogWriter.kt new file mode 100644 index 0000000000000000000000000000000000000000..baf4c7d887f311954ef2b7cd7fe29e6472e57d47 --- /dev/null +++ b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/log/FilesystemLogWriter.kt @@ -0,0 +1,97 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; version 2.1 of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package edu.ucsc.its.temerity.extensions.log + +import co.touchlab.kermit.DefaultFormatter +import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.Message +import co.touchlab.kermit.MessageStringFormatter +import co.touchlab.kermit.Severity +import co.touchlab.kermit.Tag +import kotlinx.io.buffered +import kotlinx.io.files.FileSystem +import kotlinx.io.files.Path +import kotlinx.io.writeString + +internal expect fun fileSystem(): FileSystem + +internal class FilesystemLogWriter internal constructor( + private val logPath: String, + private val logRoller: LogRoller? = null, + private val formatter: MessageStringFormatter = DefaultFormatter, +) : LogWriter() { + + // Not called since we are "Context Aware" + override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { + val fileSystem = fileSystem() + val kotlinxIoPath = Path(logPath) + + logRoller?.rollLogs(kotlinxIoPath, fileSystem) + + val sink = fileSystem.sink(kotlinxIoPath, append = true).buffered() + + with(sink) { + writeString(formatter.formatMessage(severity, Tag(tag), Message(message))) + writeString("\n") + throwable?.let { + writeString(it.stackTraceToString()) + } + flush() + } + } + + companion object { + operator fun invoke(block: Builder.() -> Unit) = + with(FilesystemLogWriterBuilder()) { + block(this) + build() + } + } + + interface Builder { + fun rollLogAtSize(size: Long): Builder + fun logPath(path: String): Builder + fun build(): FilesystemLogWriter + } + + class FilesystemLogWriterBuilder : Builder { + private var maxFileSize: Long? = null + private var logPath: String? = null + + override fun rollLogAtSize(size: Long): Builder { + maxFileSize = size + return this + } + + override fun logPath(path: String): Builder { + logPath = path + return this + } + + override fun build(): FilesystemLogWriter { + if (logPath == null) throw NullPointerException("Invalid / missing log path") + + // Can you resist the urge to Rick-roll the logs? + val rick = maxFileSize?.let { + FileSizeLogRoller(logPath!!, maxFileSize!!) + } + + return FilesystemLogWriter(logPath!!, rick) + } + } +} diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/log/Kermit.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/log/Kermit.kt new file mode 100644 index 0000000000000000000000000000000000000000..fe4e986c29965bf157c8cff8708c0cb43ebf8c43 --- /dev/null +++ b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/log/Kermit.kt @@ -0,0 +1,87 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; version 2.1 of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package edu.ucsc.its.temerity.extensions.log + +import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity +import co.touchlab.kermit.StaticConfig + +/* + * An overload for the Kermit logger that allows for more concise and readable configuration code. + * Adapted from: https://github.com/psh/KermitExt/blob/060cd7fab8a73af4187498af8ed169fa4624bf5a/kermit-config/src/commonMain/kotlin/com/gatebuzz/kermit/ext/Kermit.kt + */ +internal class Kermit { + + companion object { + @JvmStatic + fun builder(): Builder = LoggerBuilder() + + operator fun invoke(block: Builder.() -> Unit): Logger = with(LoggerBuilder()) { + block(this) + build() + } + } + + interface Builder { + fun tag(tag: String): Builder + fun minSeverity(severity: Severity): Builder + fun setLogWriters(vararg logWriter: LogWriter): Builder + fun addLogWriter(vararg logWriter: LogWriter): Builder + operator fun LogWriter.unaryPlus() + fun build(): Logger + } + + internal class LoggerBuilder : Builder { + private var tag: String = "" + private var logWriters: MutableList<LogWriter> = mutableListOf() + private var minSeverity: Severity = Severity.Verbose + + override fun tag(tag: String): Builder { + this.tag = tag + return this + } + + override fun minSeverity(severity: Severity): Builder { + this.minSeverity = severity + return this + } + + override fun setLogWriters(vararg logWriter: LogWriter): Builder { + logWriters = logWriter.toMutableList() + return this + } + + override operator fun LogWriter.unaryPlus() { + logWriters.add(this) + } + + override fun addLogWriter(vararg logWriter: LogWriter): Builder { + logWriters.addAll(logWriter) + return this + } + + override fun build(): Logger { + if (logWriters.isEmpty()) throw Exception("At least one log writer is needed") + + return Logger( + StaticConfig(minSeverity, logWriters.toList()), + ) + } + } +} diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/log/LogRoller.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/log/LogRoller.kt new file mode 100644 index 0000000000000000000000000000000000000000..69171fedb96f80f28c3987d4ae125c66336884ab --- /dev/null +++ b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/log/LogRoller.kt @@ -0,0 +1,46 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; version 2.1 of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package edu.ucsc.its.temerity.extensions.log + +import kotlinx.io.files.FileSystem +import kotlinx.io.files.Path + +internal interface LogRoller { + fun rollLogs(kotlinxIoPath: Path, fileSystem: FileSystem) +} + +internal class FileSizeLogRoller( + private val logPath: String, + private val maxFileSize: Long, +) : LogRoller { + override fun rollLogs(kotlinxIoPath: Path, fileSystem: FileSystem) { + val metadata = fileSystem.metadataOrNull(kotlinxIoPath) + metadata?.let { + it.size.let { size -> + if (size >= maxFileSize) { + val filename = kotlinxIoPath.name + val count = fileSystem.list(kotlinxIoPath.parent!!).count { p: Path -> + p.name.contains(filename) + } + val to = Path("$logPath.$count") + fileSystem.atomicMove(kotlinxIoPath, to) + } + } + } + } +} diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/log/WrappingFormatter.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/log/WrappingFormatter.kt new file mode 100644 index 0000000000000000000000000000000000000000..26703f81aca62012f948c33e137c017af0a24728 --- /dev/null +++ b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/log/WrappingFormatter.kt @@ -0,0 +1,37 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; version 2.1 of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package edu.ucsc.its.temerity.extensions.log + +import co.touchlab.kermit.Message +import co.touchlab.kermit.MessageStringFormatter +import co.touchlab.kermit.Severity +import co.touchlab.kermit.Tag + +internal abstract class WrappingFormatter( + private val messageStringFormatter: MessageStringFormatter, +) : MessageStringFormatter { + override fun formatMessage(severity: Severity?, tag: Tag?, message: Message): String { + val prefix = prefix(severity, tag, message) + val content = messageStringFormatter.formatMessage(severity, tag, message) + val suffix = suffix(severity, tag, message) + return "${prefix}$content$suffix" + } + + open fun prefix(severity: Severity?, tag: Tag?, message: Message): String = "" + open fun suffix(severity: Severity?, tag: Tag?, message: Message): String = "" +} diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/DateTimeExtensions.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/time/DateTimeExtensions.kt similarity index 97% rename from temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/DateTimeExtensions.kt rename to temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/time/DateTimeExtensions.kt index bfb164a31af64f1e313fb4c280d0e6161b6ff0ba..7ed64322b5d44f0fd76206566d0b49c49e09f028 100644 --- a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/DateTimeExtensions.kt +++ b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/time/DateTimeExtensions.kt @@ -15,7 +15,7 @@ * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ -package edu.ucsc.its.temerity.extensions +package edu.ucsc.its.temerity.extensions.time import edu.ucsc.its.temerity.AUDIT_LOG_REQUEST_DATE_FORMAT import edu.ucsc.its.temerity.SCHEDULED_SESSION_DATE_FORMAT diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/LocalDateTimeSerializer.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/time/LocalDateTimeSerializer.kt similarity index 97% rename from temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/LocalDateTimeSerializer.kt rename to temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/time/LocalDateTimeSerializer.kt index b33286c63652fd4129abecd17b1e13b064aea7af..96695252d3ce72acaa684e88cb9f2a96b62533bb 100644 --- a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/LocalDateTimeSerializer.kt +++ b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/time/LocalDateTimeSerializer.kt @@ -15,7 +15,7 @@ * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ -package edu.ucsc.its.temerity.extensions +package edu.ucsc.its.temerity.extensions.time import edu.ucsc.its.temerity.AUDIT_LOG_TIMESTAMP_FORMAT import edu.ucsc.its.temerity.model.AuditLogEntry diff --git a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/model/AuditLogs.kt b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/model/AuditLogs.kt index 538b45508ef65fd405ae3a10fbc0583fec87b7a5..ed163d057075a7e1c37d9060733833a6952ace73 100644 --- a/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/model/AuditLogs.kt +++ b/temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/model/AuditLogs.kt @@ -17,7 +17,7 @@ */ package edu.ucsc.its.temerity.model -import edu.ucsc.its.temerity.extensions.LocalDateTimeSerializer +import edu.ucsc.its.temerity.extensions.time.LocalDateTimeSerializer import kotlinx.datetime.LocalDateTime import kotlinx.serialization.Serializable diff --git a/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/core/LoggerFactory.jvm.kt b/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/core/LoggerFactory.jvm.kt new file mode 100644 index 0000000000000000000000000000000000000000..9cb64854d8f7a4c32115b92efa2ad55f5cc893cd --- /dev/null +++ b/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/core/LoggerFactory.jvm.kt @@ -0,0 +1,58 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; version 2.1 of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package edu.ucsc.its.temerity.core + +import co.touchlab.kermit.CommonWriter +import co.touchlab.kermit.Logger +import co.touchlab.kermit.NoTagFormatter +import co.touchlab.kermit.Severity.Debug +import co.touchlab.kermit.loggerConfigInit +import co.touchlab.kermit.platformLogWriter +import edu.ucsc.its.temerity.TemClientConfig + +internal actual fun createCommonLogger(tag: String?, config: TemClientConfig?, supportKtxNotebook: Boolean): Logger = when (config) { + null -> { + Logger( + config = loggerConfigInit( + if (supportKtxNotebook) CommonWriter(NoTagFormatter) else platformLogWriter(NoTagFormatter), + minSeverity = Debug, + ), + tag = tag ?: "TemerityLib", + ) + } + else -> { + when (config.optDebugEnabled) { + // If debug logging is enabled, create a logger which color prints to the console + true -> { + Logger( + config = loggerConfigInit( + if (supportKtxNotebook) CommonWriter(NoTagFormatter) else platformLogWriter(NoTagFormatter), + minSeverity = Debug, + ), + tag = tag ?: "TemerityLib", + ) + } + // If debug logging is disabled, create a logger which prints to a log file + false -> { + Logger( + config = loggerConfigInit(), + ) + } + } + } +} diff --git a/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/di/PlatformModule.jvm.kt b/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/di/PlatformModule.jvm.kt new file mode 100644 index 0000000000000000000000000000000000000000..a9845defa0bd8148131c81826c2f120f22642902 --- /dev/null +++ b/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/di/PlatformModule.jvm.kt @@ -0,0 +1,31 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; version 2.1 of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package edu.ucsc.its.temerity.di + +import io.ktor.client.engine.java.Java +import kotlinx.coroutines.CoroutineDispatcher +import org.koin.core.qualifier.named +import org.koin.dsl.module + +internal actual fun platformModule() = module { + factory(named("httpClientEngine")) { (libraryCoroutineDispatcher: CoroutineDispatcher) -> + Java.create { + dispatcher = libraryCoroutineDispatcher + } + } +} diff --git a/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/extensions/log/ColorFormatter.kt b/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/extensions/log/ColorFormatter.kt new file mode 100644 index 0000000000000000000000000000000000000000..d11bb8f44f4a9c7949808ddded9343f09f56e290 --- /dev/null +++ b/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/extensions/log/ColorFormatter.kt @@ -0,0 +1,65 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; version 2.1 of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +@file:Suppress("unused") + +package edu.ucsc.its.temerity.extensions.log + +import co.touchlab.kermit.DefaultFormatter +import co.touchlab.kermit.Message +import co.touchlab.kermit.MessageStringFormatter +import co.touchlab.kermit.Severity +import co.touchlab.kermit.Tag +import org.fusesource.jansi.Ansi + +private fun withColor(messageStringFormatter: MessageStringFormatter = DefaultFormatter): MessageStringFormatter = ColorFormatter(messageStringFormatter) + +private fun withBrightColor(messageStringFormatter: MessageStringFormatter = DefaultFormatter): MessageStringFormatter = BrightColorFormatter(messageStringFormatter) + +internal class ColorFormatter( + messageStringFormatter: MessageStringFormatter = DefaultFormatter, +) : WrappingFormatter(messageStringFormatter) { + override fun prefix(severity: Severity?, tag: Tag?, message: Message) = + severity?.toAnsiColor() ?: "" + + override fun suffix(severity: Severity?, tag: Tag?, message: Message) = + resetColor() +} + +internal class BrightColorFormatter( + messageStringFormatter: MessageStringFormatter = DefaultFormatter, +) : WrappingFormatter(messageStringFormatter) { + override fun prefix(severity: Severity?, tag: Tag?, message: Message) = + severity?.toBrightAnsiColor() ?: "" + + override fun suffix(severity: Severity?, tag: Tag?, message: Message) = resetColor() +} + +internal fun Severity.toBrightAnsiColor() = "${Ansi.ansi().fgBright(this.asColor())}" + +internal fun Severity.toAnsiColor() = "${Ansi.ansi().fg(this.asColor())}" + +internal fun resetColor() = "${Ansi.ansi().a(Ansi.Attribute.RESET)}" + +internal fun Severity.asColor(): Ansi.Color = when (this) { + Severity.Verbose -> Ansi.Color.WHITE + Severity.Debug -> Ansi.Color.CYAN + Severity.Info -> Ansi.Color.GREEN + Severity.Warn -> Ansi.Color.YELLOW + Severity.Error -> Ansi.Color.RED + Severity.Assert -> Ansi.Color.MAGENTA +} diff --git a/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/Actual.kt b/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/extensions/log/Filesystem.jvm.kt similarity index 83% rename from temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/Actual.kt rename to temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/extensions/log/Filesystem.jvm.kt index 8574a6a44702540df719f632c6979650a0538f42..094b3d7530deffde589ca3ccadd23ad63130c62f 100644 --- a/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/Actual.kt +++ b/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/extensions/log/Filesystem.jvm.kt @@ -15,11 +15,9 @@ * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ -package edu.ucsc.its.temerity +package edu.ucsc.its.temerity.extensions.log -import io.ktor.client.engine.java.Java -import org.koin.dsl.module +import kotlinx.io.files.FileSystem +import kotlinx.io.files.SystemFileSystem -internal actual fun platformModule() = module { - factory { Java.create() } -} +internal actual fun fileSystem(): FileSystem = SystemFileSystem diff --git a/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/extensions/log/KermitServiceProvider.kt b/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/extensions/log/KermitServiceProvider.kt new file mode 100644 index 0000000000000000000000000000000000000000..2f4e0b8a4687d191296792ed2c4617062eefa755 --- /dev/null +++ b/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/extensions/log/KermitServiceProvider.kt @@ -0,0 +1,68 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; version 2.1 of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +@file:Suppress("unused") + +package edu.ucsc.its.temerity.extensions.log + +import co.touchlab.kermit.CommonWriter +import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.Severity +import co.touchlab.kermit.StaticConfig +import org.slf4j.ILoggerFactory +import org.slf4j.helpers.BasicMarkerFactory +import org.slf4j.helpers.NOPMDCAdapter + +internal class KermitServiceProvider : org.slf4j.spi.SLF4JServiceProvider { + private val markerFactory = BasicMarkerFactory() + private val mdcAdapter = NOPMDCAdapter() + override fun getLoggerFactory() = ILoggerFactory { + Slf4jKermitLogger(it, config) + } + + override fun getMarkerFactory() = markerFactory + + override fun getMDCAdapter() = mdcAdapter + + override fun getRequestedApiVersion() = "2.0.99" + + override fun initialize() = Unit + + companion object { + private val writers = mutableListOf<LogWriter>().apply { + add(CommonWriter()) + } + var config: StaticConfig = StaticConfig(logWriterList = writers) + + var minSeverity: Severity + get() = config.minSeverity + set(value) { + config = config.copy(minSeverity = value) + } + + fun addWriter(writer: LogWriter) { + writers.add(writer) + config = config.copy(logWriterList = writers) + } + + fun setWriters(vararg writer: LogWriter) { + writers.clear() + writers.addAll(writer) + config = config.copy(logWriterList = writers) + } + } +} diff --git a/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/extensions/log/Slf4jKermitLogger.kt b/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/extensions/log/Slf4jKermitLogger.kt new file mode 100644 index 0000000000000000000000000000000000000000..d5ea3eb24c621c3610145a9519817e34535d526a --- /dev/null +++ b/temerity/src/jvmMain/kotlin/edu/ucsc/its/temerity/extensions/log/Slf4jKermitLogger.kt @@ -0,0 +1,78 @@ +/* + * Designed and developed in 2022-2024 by William Walker (wnwalker@ucsc.edu) + * Copyright 2022-2024 The Regents of the University of California. All rights reserved. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; version 2.1 of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +@file:Suppress("unused") + +package edu.ucsc.its.temerity.extensions.log + +import co.touchlab.kermit.BaseLogger +import co.touchlab.kermit.LoggerConfig +import co.touchlab.kermit.Severity +import org.slf4j.Marker +import org.slf4j.event.Level +import org.slf4j.helpers.AbstractLogger + +internal class Slf4jKermitLogger(private val name: String, config: LoggerConfig) : AbstractLogger() { + private val logger = BaseLogger(config) + override fun getName(): String = "slf4j-over-kermit" + + //region Is Logging enabled at various levels + override fun isTraceEnabled() = logger.config.minSeverity <= Severity.Verbose + override fun isTraceEnabled(marker: Marker?) = logger.config.minSeverity <= Severity.Verbose + override fun isDebugEnabled() = logger.config.minSeverity <= Severity.Debug + override fun isDebugEnabled(marker: Marker?) = logger.config.minSeverity <= Severity.Debug + override fun isInfoEnabled() = logger.config.minSeverity <= Severity.Info + override fun isInfoEnabled(marker: Marker?) = logger.config.minSeverity <= Severity.Info + override fun isWarnEnabled() = logger.config.minSeverity <= Severity.Warn + override fun isWarnEnabled(marker: Marker?) = logger.config.minSeverity <= Severity.Warn + override fun isErrorEnabled() = logger.config.minSeverity <= Severity.Error + override fun isErrorEnabled(marker: Marker?) = logger.config.minSeverity <= Severity.Error + //endregion + + override fun getFullyQualifiedCallerName(): String? = null + + override fun handleNormalizedLoggingCall( + level: Level?, + marker: Marker?, + messagePattern: String?, + arguments: Array<out Any>?, + throwable: Throwable?, + ) { + val severity = when (level) { + Level.ERROR -> Severity.Error + Level.WARN -> Severity.Warn + Level.INFO -> Severity.Info + Level.DEBUG -> Severity.Debug + else -> Severity.Verbose + } + + val formatted = if (messagePattern != null && arguments != null) { + String.format(messagePattern, *(arguments.toList().toTypedArray())) + } else { + null + } + + messagePattern.let { + logger.log( + severity, + marker?.toString() ?: name, + throwable, + formatted ?: (messagePattern ?: ""), + ) + } + } +} diff --git a/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/DevDeviceApiTests.kt b/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/DevDeviceApiTests.kt index a629715af9f553b9f07284e8bcc20299c40b58a0..058672e8279d74875a658994c3975424e65c6dbb 100644 --- a/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/DevDeviceApiTests.kt +++ b/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/DevDeviceApiTests.kt @@ -20,11 +20,10 @@ package edu.ucsc.its.temerity.test import edu.ucsc.its.temerity.core.Temerity import io.kotest.core.spec.style.FunSpec import kotlinx.coroutines.runBlocking -import org.dotenv.vault.dotenvVault class DevDeviceApiTests : FunSpec({ - val dotenv = dotenvVault() + val dotenv = dotenvVaultJvm() lateinit var temerityTest: Temerity diff --git a/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/DevGroupApiTests.kt b/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/DevGroupApiTests.kt index 7457d64fed42fe280fe04e6f2764a30733fee869..3658f2465eaef2f5dc443af31816e9b5143d2296 100644 --- a/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/DevGroupApiTests.kt +++ b/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/DevGroupApiTests.kt @@ -20,12 +20,11 @@ package edu.ucsc.its.temerity.test import edu.ucsc.its.temerity.core.Temerity import io.kotest.core.spec.style.FunSpec import kotlinx.coroutines.runBlocking -import org.dotenv.vault.dotenvVault class DevGroupApiTests : FunSpec({ - val dotenv = dotenvVault() + val dotenv = dotenvVaultJvm() lateinit var testTemerity: Temerity diff --git a/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/DevUserApiTests.kt b/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/DevUserApiTests.kt index b40a4a98607b600e61a43092f05d73aeb75588d6..57ac043bbcff13f64448a341ebe7edf68855b45a 100644 --- a/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/DevUserApiTests.kt +++ b/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/DevUserApiTests.kt @@ -21,18 +21,19 @@ import co.touchlab.kermit.Logger import com.skydoves.sandwich.StatusCode import com.skydoves.sandwich.ktor.getStatusCode import edu.ucsc.its.temerity.core.Temerity +import edu.ucsc.its.temerity.core.Temerity.Companion.createLogger import edu.ucsc.its.temerity.model.NewUser import edu.ucsc.its.temerity.model.UserUpdate import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.ktor.http.HttpStatusCode import kotlinx.coroutines.runBlocking -import org.dotenv.vault.dotenvVault class DevUserApiTests : FunSpec({ - val dotenv = dotenvVault() + val dotenv = dotenvVaultJvm() + val kermit = createLogger(tag = "DevUserApiTests") lateinit var testTemerity: Temerity beforeTest { @@ -45,7 +46,7 @@ class DevUserApiTests : runBlocking { val returnedUsers = testTemerity.getUsers() println("Returned users:") - returnedUsers.forEach { Logger.d(it.toString()) } + returnedUsers.forEach { kermit.d(it.toString()) } assert(returnedUsers.isNotEmpty()) } } diff --git a/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/DevUtilityTests.kt b/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/DevUtilityTests.kt index 565875c2a00f42a90a583552d0ab15828d8fb796..19a62a3a5572b022f7f77feddacdf38692e2b5a7 100644 --- a/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/DevUtilityTests.kt +++ b/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/DevUtilityTests.kt @@ -17,51 +17,47 @@ */ package edu.ucsc.its.temerity.test -import co.touchlab.kermit.Logger import edu.ucsc.its.temerity.core.Temerity -import edu.ucsc.its.temerity.di.loggerModule -import io.kotest.core.extensions.Extension +import edu.ucsc.its.temerity.core.Temerity.Companion.createLogger import io.kotest.core.spec.style.FunSpec -import kotlinx.datetime.Clock -import kotlinx.datetime.TimeZone -import kotlinx.datetime.todayIn -import org.dotenv.vault.dotenvVault -import org.koin.core.parameter.parametersOf +import io.kotest.engine.runBlocking import org.koin.test.KoinTest -import org.koin.test.inject +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes class DevUtilityTests : FunSpec(), KoinTest { - override fun extensions(): List<Extension> = listOf(koinExtension(loggerModule())) - private val kermit: Logger by inject { parametersOf("DevUtilityTests") } + private val kermit = createLogger("DevUtilityTests") init { coroutineDebugProbes = true - val dotenv = dotenvVault() + val dotenv = dotenvVaultJvm() lateinit var testTemerity: Temerity - val today = Clock.System.todayIn(TimeZone.currentSystemDefault()) beforeTest { testTemerity = Temerity { configureDevEnvironment(dotenv) } } - timeout = 5.minutes.inWholeMilliseconds - test("Delete all Canvas Test Student Users") { - val testStudentList = testTemerity.getUsers().filter { - it.firstName.startsWith("test", ignoreCase = true) && - it.lastName.startsWith("student", ignoreCase = true) && - it.emailAddress.isEmpty() - } - testStudentList.forEach { - testTemerity.deleteUser(it.userId) + test("Delete all Canvas Test Student Users").config(blockingTest = true, timeout = 12.hours) { + runBlocking { + var deletedUsers = 0 + val testStudentList = testTemerity.getUsers().filter { + it.firstName.startsWith("test", ignoreCase = true) && + it.lastName.startsWith("student", ignoreCase = true) && + it.emailAddress.isEmpty() + } + testStudentList.forEach { + testTemerity.deleteUser(it.userId) + deletedUsers++ + } + kermit.d("Deleted $deletedUsers test student users.") } } } diff --git a/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/ProdReportTests.kt b/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/ProdReportTests.kt index 7dd2e2af56f74835c7629cb7da6faa6e0a9cf462..5ce6590495267cbee21e8aa098b035297fe23dbd 100644 --- a/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/ProdReportTests.kt +++ b/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/ProdReportTests.kt @@ -16,10 +16,11 @@ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ package edu.ucsc.its.temerity.test + import edu.ucsc.its.temerity.AuditLogSortOrder.NEW_FIRST import edu.ucsc.its.temerity.core.Temerity -import edu.ucsc.its.temerity.currentDate -import edu.ucsc.its.temerity.di.loggerModule +import edu.ucsc.its.temerity.core.Temerity.Companion.createLogger +import edu.ucsc.its.temerity.core.Temerity.Companion.currentDate import edu.ucsc.its.temerity.model.EventType.AUTOMATED_SESSION_FAILED_TO_START import edu.ucsc.its.temerity.model.EventType.AUTOMATED_SESSION_MONITOR import edu.ucsc.its.temerity.model.EventType.CAPTURE_ERROR @@ -31,7 +32,6 @@ import edu.ucsc.its.temerity.model.EventType.HDCP_SIGNAL_DETECTED import edu.ucsc.its.temerity.model.EventType.HUB_CAPTURE_PROBLEM import edu.ucsc.its.temerity.model.EventType.NEW_LOG_IN import edu.ucsc.its.temerity.model.EventType.RECORDING_ERROR -import io.kotest.core.extensions.Extension import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.file.shouldNotBeEmpty import kotlinx.coroutines.runBlocking @@ -40,12 +40,9 @@ import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.TimeZone import kotlinx.datetime.minus import kotlinx.datetime.todayIn -import org.dotenv.vault.dotenvVault import org.jetbrains.kotlinx.dataframe.api.toDataFrame import org.jetbrains.kotlinx.dataframe.io.writeCSV -import org.koin.core.parameter.parametersOf import org.koin.test.KoinTest -import org.koin.test.inject import java.io.File import kotlin.time.Duration.Companion.minutes import kotlin.uuid.ExperimentalUuidApi @@ -57,16 +54,15 @@ class ProdReportTests : FunSpec(), KoinTest { - override fun extensions(): List<Extension> = listOf(koinExtension(loggerModule())) - private val kermit: KermitLogger by inject { parametersOf("ProdReportTests") } + private val kermit: KermitLogger = createLogger(tag = "TemerityDevTest") init { coroutineDebugProbes = true - val dotenv = dotenvVault() + val dotenv = dotenvVaultJvm() lateinit var testTemerity: Temerity - val today = Clock.System.todayIn(TimeZone.currentSystemDefault()) + val today = currentDate() beforeTest { testTemerity = Temerity { diff --git a/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/ProdUtilityTests.kt b/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/ProdUtilityTests.kt index 0f6deec359cb1b16f0d1208c285fdb22af50d7b6..6e43ac6f5bd8bb39733c6c4f378454d9876b6c16 100644 --- a/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/ProdUtilityTests.kt +++ b/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/ProdUtilityTests.kt @@ -17,34 +17,29 @@ */ package edu.ucsc.its.temerity.test -import co.touchlab.kermit.Logger import edu.ucsc.its.temerity.core.Temerity -import edu.ucsc.its.temerity.di.loggerModule -import io.kotest.core.extensions.Extension +import edu.ucsc.its.temerity.core.Temerity.Companion.createLogger +import io.kotest.common.ExperimentalKotest import io.kotest.core.spec.style.FunSpec -import kotlinx.datetime.Clock -import kotlinx.datetime.TimeZone -import kotlinx.datetime.todayIn -import org.dotenv.vault.dotenvVault -import org.koin.core.parameter.parametersOf +import io.kotest.engine.runBlocking import org.koin.test.KoinTest -import org.koin.test.inject +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes class ProdUtilityTests : FunSpec(), KoinTest { - override fun extensions(): List<Extension> = listOf(koinExtension(loggerModule())) - private val kermit: Logger by inject { parametersOf("ProdUtilityTests") } + private val kermit = createLogger("ProdUtilityTests") init { coroutineDebugProbes = true + @OptIn(ExperimentalKotest::class) + blockingTest = true - val dotenv = dotenvVault() + val dotenv = dotenvVaultJvm() lateinit var testTemerity: Temerity - val today = Clock.System.todayIn(TimeZone.currentSystemDefault()) beforeTest { testTemerity = Temerity { @@ -54,14 +49,19 @@ class ProdUtilityTests : timeout = 5.minutes.inWholeMilliseconds - test("Delete all Canvas Test Student Users") { - val testStudentList = testTemerity.getUsers().filter { - it.firstName.startsWith("test", ignoreCase = true) && - it.lastName.startsWith("student", ignoreCase = true) && - it.emailAddress.isEmpty() - } - testStudentList.forEach { - testTemerity.deleteUser(it.userId) + test("Delete all Canvas Test Student Users").config(blockingTest = true, timeout = 12.hours) { + runBlocking { + var deletedUsers = 0 + val testStudentList = testTemerity.getUsers().filter { + it.firstName.startsWith("test", ignoreCase = true) && + it.lastName.startsWith("student", ignoreCase = true) && + it.emailAddress.isEmpty() + } + testStudentList.forEach { + testTemerity.deleteUser(it.userId) + deletedUsers++ + } + kermit.d("Deleted $deletedUsers test student users.") } } } diff --git a/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/TemerityDevTest.kt b/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/TemerityDevTest.kt index 3ba2506dab26aff2d4818a16c9a7971953896a6e..51fd825ba227df0925ac95ba04aa83696f9dbf26 100644 --- a/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/TemerityDevTest.kt +++ b/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/TemerityDevTest.kt @@ -22,32 +22,32 @@ import com.skydoves.sandwich.StatusCode import com.skydoves.sandwich.ktor.getStatusCode import edu.ucsc.its.temerity.AuditLogSortOrder.NEW_FIRST import edu.ucsc.its.temerity.core.Temerity -import edu.ucsc.its.temerity.currentDate +import edu.ucsc.its.temerity.core.Temerity.Companion.currentDate import edu.ucsc.its.temerity.model.EventType.NEW_LOG_IN import edu.ucsc.its.temerity.model.NewUser +import io.github.z4kn4fein.semver.Version +import io.github.z4kn4fein.semver.toVersion import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.file.shouldNotBeEmpty import io.kotest.matchers.shouldBe import kotlinx.coroutines.runBlocking -import kotlinx.datetime.Clock import kotlinx.datetime.DateTimeUnit -import kotlinx.datetime.TimeZone import kotlinx.datetime.minus import kotlinx.datetime.plus -import kotlinx.datetime.toLocalDateTime -import kotlinx.datetime.todayIn -import org.dotenv.vault.dotenvVault import org.jetbrains.kotlinx.dataframe.api.toDataFrame import org.jetbrains.kotlinx.dataframe.io.writeCSV import java.io.File +import kotlin.test.assertNotEquals import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @OptIn(ExperimentalUuidApi::class) -class TemerityDevTest : - FunSpec({ +class TemerityDevTest : FunSpec() { - val dotenv = dotenvVault() + private lateinit var kermit: Logger + + init { + val dotenv = dotenvVaultJvm() lateinit var testTemerity: Temerity @@ -55,11 +55,30 @@ class TemerityDevTest : testTemerity = Temerity { configureDevEnvironment(dotenv) } + kermit = Temerity.createLogger("TemerityDevTest") + } + + test("Temerity client returns a correctly-formatted version String") { + val returnedVersion = testTemerity.version.toVersion() + kermit.d { "Returned client version: $returnedVersion" } + assert(testTemerity.version.isNotEmpty()) + assert(returnedVersion < Version(0, 1, 0)) + assert(returnedVersion.isPreRelease) + } + + test("Temerity client returns different Koin Application instances on each creation") { + val firstInstance = Temerity { + configureDevEnvironment(dotenv) + } + val secondInstance = Temerity { + configureDevEnvironment(dotenv) + } + assertNotEquals(firstInstance.koinContext, secondInstance.koinContext) } test("Get all audit log entries of type \"New Login\" from the past month") { runBlocking { - val currentDate = Clock.System.todayIn(TimeZone.currentSystemDefault()) + val currentDate = currentDate() val pastDate = currentDate.minus(1, DateTimeUnit.MONTH) val returnedAuditLogEntries = testTemerity.getAuditLogEntries( @@ -75,7 +94,7 @@ class TemerityDevTest : } test("Get all audit logs from the past month") { runBlocking { - val currentDate = Clock.System.todayIn(TimeZone.currentSystemDefault()) + val currentDate = currentDate() val pastDate = currentDate.minus(1, DateTimeUnit.MONTH) val logs = testTemerity.getAuditLogEntries(pastDate, currentDate, sortOrder = NEW_FIRST) println("Returned events from the past month:") @@ -85,7 +104,7 @@ class TemerityDevTest : } test("Get all audit logs from the past day") { runBlocking { - val currentDate = Clock.System.todayIn(TimeZone.currentSystemDefault()) + val currentDate = currentDate() val pastDate = currentDate.minus(1, DateTimeUnit.DAY) val logs = testTemerity.getAuditLogEntries(pastDate, currentDate, sortOrder = NEW_FIRST) Logger.d("Returned events from the past day:") @@ -95,7 +114,7 @@ class TemerityDevTest : } test("Get all recording sessions belonging to a single user scheduled for the next week") { runBlocking { - val pastDate = Clock.System.todayIn(TimeZone.currentSystemDefault()).minus(1, DateTimeUnit.DAY) + val pastDate = currentDate().minus(1, DateTimeUnit.DAY) val futureDate = pastDate.plus(1, DateTimeUnit.MONTH) val sessions = testTemerity.getUserSessions("dotenv[YUJADEV_TEST_USER]", pastDate, futureDate) Logger.d("Returned sessions for the next week:") @@ -122,7 +141,7 @@ class TemerityDevTest : test("Get all recording sessions on a single device scheduled for the next week") { runBlocking { - val pastDate = Clock.System.todayIn(TimeZone.currentSystemDefault()).minus(1, DateTimeUnit.DAY) + val pastDate = currentDate().minus(1, DateTimeUnit.DAY) val futureDate = pastDate.plus(1, DateTimeUnit.MONTH) val testDeviceId = dotenv["YUJADEV_TEST_DEVICE_ID"].toLongOrNull() val sessions = testTemerity.getDeviceSchedule(testDeviceId!!, pastDate, futureDate) @@ -133,17 +152,10 @@ class TemerityDevTest : } } - test("test datetime tostring formatting") { - runBlocking { - val time = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) - println("Current time: $time") - } - } - test("Get all audit log entries of type \"New Login\" from the past day") { runBlocking { val testEventType = NEW_LOG_IN - val currentDate = Clock.System.todayIn(TimeZone.currentSystemDefault()) + val currentDate = currentDate() val pastDate = currentDate.minus(1, DateTimeUnit.DAY) val returnedAuditLogEntries = testTemerity.getAuditLogEntries( @@ -188,4 +200,5 @@ class TemerityDevTest : Logger.d(response.toString()) } } - }) + } +} diff --git a/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/Util.kt b/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/Util.kt index 5952412206b4a0b89719df0efa9dd5ac1c40d52a..1f0e87313894ddac9d6c2ceb8b4a9bad9e8166cc 100644 --- a/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/Util.kt +++ b/temerity/src/jvmTest/kotlin/edu/ucsc/its/temerity/test/Util.kt @@ -18,7 +18,9 @@ package edu.ucsc.its.temerity.test import edu.ucsc.its.temerity.TemClientConfig +import io.github.cdimascio.dotenv.Configuration import io.github.cdimascio.dotenv.Dotenv +import io.github.cdimascio.dotenv.dotenv import io.kotest.core.extensions.TestCaseExtension import io.kotest.core.test.TestCase import io.kotest.core.test.TestResult @@ -85,3 +87,10 @@ internal fun TemClientConfig.configureProdEnvironment(dotenv: Dotenv) { serviceToken = dotenv["YUJAPROD_TOKEN"] optDebugEnabled = true } + +fun dotenvVaultJvm(block: (Configuration.() -> Unit)? = null): Dotenv = when (block) { + null -> dotenv() + else -> dotenv { + block() + } +}