From ac802508462301af0649414c7faf49f60d7c7e91 Mon Sep 17 00:00:00 2001
From: William Walker <wnwalker@ucsc.edu>
Date: Tue, 17 Dec 2024 19:57:23 -0800
Subject: [PATCH] Request Logic & Logging

-Improve readability of request logic; move requests off separate dispatcher since they are already executed on it inside the HttpClient
-Simplify library dispatcher initialization by using default constructor instead of helper fun()
-Add DSL for interacting with Kermit Logger builder interface courtesy of https://github.com/psh/KermitExt/blob/060cd7fab8a73af4187498af8ed169fa4624bf5a/kermit-config/src/commonMain/kotlin/com/gatebuzz/kermit/ext/Kermit.kt
---
 gradle/libs.versions.toml                     |   2 +-
 .../edu/ucsc/its/temerity/core/Temerity.kt    | 110 +++++++++++-------
 .../extensions/coroutines/CoroutinesExt.kt    |   3 +-
 .../its/temerity/extensions/log/Kermit.kt     |  87 ++++++++++++++
 4 files changed, 156 insertions(+), 46 deletions(-)
 create mode 100644 temerity/src/commonMain/kotlin/edu/ucsc/its/temerity/extensions/log/Kermit.kt

diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 8c272be..e58ee35 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -59,7 +59,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"
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 fdb62b1..56a3eb3 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
@@ -241,9 +241,7 @@ public class Temerity internal constructor(
         else -> Unit
       }
     }
-    val apiResponsePayload = createJobScope(webRequestDispatcher).run {
-      response.getOrThrow()
-    }
+    val apiResponsePayload = response.getOrThrow()
 
     if (apiResponsePayload == "[]") throw BreakException(page - 1)
     // Handle deserialization exceptions. Queue up decode:
@@ -271,12 +269,14 @@ public class Temerity internal constructor(
   public override suspend fun getUsers(): List<User> =
     withContext(libraryCoroutineDispatcher) {
       val userRequest = platformApi.getUsers()
-      decodeResponseCatching(userRequest.executeApiResponse<String>())
+      val response = userRequest.executeApiResponse<String>()
+      decodeResponseCatching(response)
     }
 
   public override suspend fun getUser(userId: Long): User =
     withContext(libraryCoroutineDispatcher) {
-      getUsers().first { it.userId == userId }
+      val returnedUsers = getUsers()
+      returnedUsers.first { it.userId == userId }
     }
 
   public override suspend fun createUser(primaryIdentifier: String, newUser: NewUser): HttpResponse =
@@ -285,24 +285,36 @@ public class Temerity internal constructor(
       if (validationMessage != "Valid") {
         error(validationMessage)
       } else {
-        platformApi.createUser(primaryIdentifier, json.encodeToString(newUser)).getOrThrow()
+        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 =
     withContext(libraryCoroutineDispatcher) {
-      platformApi.setUser(userId, json.encodeToString(userUpdate)).getOrThrow()
+      val serializedUpdatedUser = createJobScope(jsonProcessingDispatcher).run {
+        json.encodeToString(userUpdate)
+      }
+      val request = platformApi.setUser(userId, serializedUpdatedUser)
+      request.getOrThrow()
     }
 
   public override suspend fun deleteUser(userId: Long): HttpResponse =
     withContext(libraryCoroutineDispatcher) {
-      platformApi.deleteUser(userId).getOrThrow()
+      val request = platformApi.deleteUser(userId)
+      request.getOrThrow()
     }
 
   public override suspend fun refreshCachedUserRoles(): List<String> =
     withContext(libraryCoroutineDispatcher) {
-      val returnedUsersResponse = platformApi.getUsers().executeApiResponse<String>().getOrThrow()
-      val returnedUserList = json.decodeFromString<List<User>>(returnedUsersResponse)
+      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 {
@@ -324,11 +336,15 @@ public class Temerity internal constructor(
 
   public override suspend fun getUserGroups(userId: Long): List<UserGroup> =
     withContext(libraryCoroutineDispatcher) {
-      decodeResponseCatching(platformApi.getUserGroups(userId).executeApiResponse<String>())
+      val request = platformApi.getUserGroups(userId)
+      val response = request.executeApiResponse<String>()
+      decodeResponseCatching(response)
     }
 
   public override suspend fun getUserGroupsOwned(userId: Long): List<UserGroup> = withContext(libraryCoroutineDispatcher) {
-    decodeResponseCatching<List<UserGroup>>(platformApi.getUserGroupsOwned(userId).executeApiResponse())
+    val request = platformApi.getUserGroupsOwned(userId)
+    val response = request.executeApiResponse<String>()
+    decodeResponseCatching<List<UserGroup>>(response)
   }
 
   public override suspend fun createUserWithExternalId(primaryIdentifier: String, newUser: NewUser): HttpResponse {
@@ -353,10 +369,10 @@ public class Temerity internal constructor(
         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) {
@@ -474,8 +490,11 @@ public class Temerity internal constructor(
                 entryOffset = x * 75,
                 eventType = eventType.value,
               )
+              val response = createJobScope(webRequestDispatcher).run {
+                endpointRequest.executeApiResponse<String>()
+              }
               val logEventEntries: List<AuditLogEntry> =
-                decodeResponseCatching(endpointRequest.executeApiResponse<String>(), page = x)
+                decodeResponseCatching(response, page = x)
               returnedEntries.addAll(logEventEntries)
             }
           } catch (e: Exception) {
@@ -505,26 +524,31 @@ public class Temerity internal constructor(
     }
   }
 
-  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(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> = withContext(libraryCoroutineDispatcher) {
-    decodeResponseCatching(platformApi.getDevices().executeApiResponse<String>())
+    val request = platformApi.getDevices()
+    val response = request.executeApiResponse<String>()
+    decodeResponseCatching(response)
   }
 
   public override suspend fun getDevice(deviceId: Long): Device = withContext(libraryCoroutineDispatcher) {
-    decodeResponseCatching(platformApi.getDeviceById(deviceId).executeApiResponse<String>())
+    val request = platformApi.getDeviceById(deviceId)
+    val response = request.executeApiResponse<String>()
+    decodeResponseCatching(response)
   }
 
   public override suspend fun getUserSessions(
@@ -532,13 +556,13 @@ public class Temerity internal constructor(
     startTime: LocalDate,
     endTime: LocalDate,
   ): List<UserRecordingSession> = withContext(libraryCoroutineDispatcher) {
-    decodeResponseCatching(
-      platformApi.getUserSessions(
-        userId,
-        startTime.applyScheduledSessionDateFormat(),
-        endTime.applyScheduledSessionDateFormat(),
-      ).executeApiResponse<String>(),
+    val request = platformApi.getUserSessions(
+      userId,
+      startTime.applyScheduledSessionDateFormat(),
+      endTime.applyScheduledSessionDateFormat(),
     )
+    val response = request.executeApiResponse<String>()
+    decodeResponseCatching(response)
   }
 
   public override suspend fun getDeviceSchedule(
@@ -546,20 +570,18 @@ public class Temerity internal constructor(
     startTime: LocalDate,
     endTime: LocalDate,
   ): List<DeviceRecordingSession> = withContext(libraryCoroutineDispatcher) {
-    decodeResponseCatching(
-      platformApi.getDeviceSchedule(
-        deviceId,
-        startTime.applyScheduledSessionDateFormat(),
-        endTime.applyScheduledSessionDateFormat(),
-      ).executeApiResponse<String>(),
+    val request = platformApi.getDeviceSchedule(
+      deviceId,
+      startTime.applyScheduledSessionDateFormat(),
+      endTime.applyScheduledSessionDateFormat(),
     )
+    val response = request.executeApiResponse<String>()
+    decodeResponseCatching(response)
   }
 
   public override suspend fun getStorageAnalyticsReport(groupId: Long): ByteArray = withContext(libraryCoroutineDispatcher) {
     val apiRequest = platformApi.getStorageAnalyticsReport(groupId)
-    val responseBytes = createJobScope(webRequestDispatcher).run {
-      apiRequest.executeApiResponse<ByteReadChannel>().getOrThrow()
-    }
+    val responseBytes = apiRequest.executeApiResponse<ByteReadChannel>().getOrThrow()
     withContext(fileProcessDispatcher) {
       responseBytes.toByteArray(limit = 2000000000)
     } // Limit file downloads to 2 GB
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
index d9790ad..402405c 100644
--- 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
@@ -18,6 +18,7 @@
 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
@@ -29,7 +30,7 @@ internal fun createJobScope(coroutineContext: CoroutineContext, allowIndependent
   return CoroutineScope(coroutineContext + parentJob)
 }
 
-internal fun createLibraryScope(dispatcher: CoroutineDispatcher): CoroutineScope = createJobScope(dispatcher, allowIndependentFailure = true)
+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
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 0000000..fe4e986
--- /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()),
+      )
+    }
+  }
+}
-- 
GitLab