diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt index 460e5bcf..0707cd14 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt @@ -1,8 +1,10 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.remote.OkHttpProvider -import kotlinx.coroutines.CancellationException -import kotlinx.serialization.json.Json +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.PluginsManageParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement @@ -11,23 +13,14 @@ import kotlinx.serialization.json.decodeFromJsonElement * Manages plugin settings and inspection through the gateway's canonical `plugins.manage` RPC. */ class PluginManageRepository( - private val rpcRequest: suspend (String, Map) -> Any? = { method, params -> - val deferred = HermesWsClient.request(method, params.filterValues { it != null }.mapValues { it.value ?: "" }) - try { - deferred.await() - } catch (e: CancellationException) { - deferred.cancel(e) - throw e - } - }, + private val caller: TypedRpcCaller = HermesRpcCaller, ) { suspend fun listPlugins(profile: String? = null): List { - val params = mutableMapOf("action" to "list") - if (!profile.isNullOrBlank()) { - params["profile"] = profile - } - val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params) - val element = result.toJsonElement() + val element = + caller.call( + RpcMethods.PLUGINS_MANAGE, + PluginsManageParams(action = "list", profile = profile.orNullIfBlank()), + ) val pluginsElement = (element as? JsonObject)?.get("plugins") ?: return emptyList() return OkHttpProvider.json.decodeFromJsonElement>(pluginsElement) } @@ -38,15 +31,15 @@ class PluginManageRepository( profile: String? = null, ): PluginsManageSettingsResult { val params = - mutableMapOf( - "action" to "settings", - "key" to key, - "values" to JsonObject(values), + PluginsManageParams( + action = "settings", + key = key, + values = JsonObject(values), + profile = profile.orNullIfBlank(), ) - if (!profile.isNullOrBlank()) { - params["profile"] = profile - } - val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params) - return OkHttpProvider.json.decodeFromJsonElement(result.toJsonElement()) + val result = caller.call(RpcMethods.PLUGINS_MANAGE, params) + return OkHttpProvider.json.decodeFromJsonElement(result) } + + private fun String?.orNullIfBlank(): String? = this?.takeIf { it.isNotBlank() } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt index 92766afe..a2cea993 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt @@ -1,6 +1,9 @@ package com.m57.hermescontrol.data.ws -import kotlinx.coroutines.CancellationException +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.PluginsManageParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromJsonElement @@ -14,18 +17,10 @@ data class PluginRemovalResult( /** Removes a user-installed plugin through the gateway's canonical plugin manager. */ class PluginRemovalRepository( - private val rpcRequest: suspend (String, Map) -> Any? = { method, params -> - val deferred = HermesWsClient.request(method, params) - try { - deferred.await() - } catch (e: CancellationException) { - deferred.cancel(e) - throw e - } - }, + private val caller: TypedRpcCaller = HermesRpcCaller, ) { suspend fun remove(name: String): PluginRemovalResult { - val result = rpcRequest(WsMethods.PLUGINS_MANAGE, mapOf("action" to "remove", "name" to name)) - return Json.decodeFromJsonElement(result.toJsonElement()) + val result = caller.call(RpcMethods.PLUGINS_MANAGE, PluginsManageParams(action = "remove", name = name)) + return Json.decodeFromJsonElement(result) } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt index f1984bee..7d790234 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt @@ -3,6 +3,10 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.model.ProjectInfo import com.m57.hermescontrol.data.model.ProjectsListResponse import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.ProjectsListParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller import kotlinx.coroutines.CancellationException import kotlinx.serialization.json.JsonObject @@ -17,14 +21,12 @@ class HermesProjectsSource( // Only ask a live socket: an offline request would be queued and force a reconnect on // every refresh. The view model refetches when the connection comes back. private val isConnected: () -> Boolean = { HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED }, - private val rpcRequest: suspend (method: String, params: Map) -> Any? = { method, params -> - HermesWsClient.request(method, params).await() - }, + private val caller: TypedRpcCaller = HermesRpcCaller, ) : ProjectsSource { override suspend fun fetchProjects(): List? { if (!isConnected()) return null return try { - val element = SessionLiveStatusDecoder.anyToJsonElement(rpcRequest(WsMethods.PROJECTS_LIST, emptyMap())) + val element = caller.call(RpcMethods.PROJECTS_LIST, ProjectsListParams) if (element is JsonObject && element.containsKey("projects")) { OkHttpProvider.json.decodeFromJsonElement(ProjectsListResponse.serializer(), element).projects } else { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/PluginsManageParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/PluginsManageParams.kt new file mode 100644 index 00000000..e970a9bd --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/PluginsManageParams.kt @@ -0,0 +1,25 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +/** + * `plugins.manage` params. [action] has no default so it is always encoded; every other field is + * omitted when null. [profile] is an explicit override only; otherwise WsProfileParams injects it. + */ +@Serializable +data class PluginsManageParams( + @SerialName("action") val action: String, + @SerialName("profile") val profile: String? = null, + @SerialName("key") val key: String? = null, + @SerialName("name") val name: String? = null, + @SerialName("enable") val enable: Boolean? = null, + @SerialName("identifier") val identifier: String? = null, + @SerialName("repo") val repo: String? = null, + @SerialName("catalog_name") val catalogName: String? = null, + @SerialName("force") val force: Boolean? = null, + @SerialName("ref") val ref: String? = null, + @SerialName("accept_capabilities") val acceptCapabilities: Boolean? = null, + @SerialName("values") val values: JsonObject? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProjectsListParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProjectsListParams.kt new file mode 100644 index 00000000..8b8937cc --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProjectsListParams.kt @@ -0,0 +1,7 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.Serializable + +/** `projects.list` takes no explicit params; the active profile is injected by WsProfileParams. */ +@Serializable +data object ProjectsListParams diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index 249962e9..4b59ac9c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -363,6 +363,22 @@ object RpcMethods { JsonElement.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PLUGINS_MANAGE: RpcMethod = + RpcMethod( + WsMethods.PLUGINS_MANAGE, + PluginsManageParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PROJECTS_LIST: RpcMethod = + RpcMethod( + WsMethods.PROJECTS_LIST, + ProjectsListParams.serializer(), + JsonElement.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -410,5 +426,7 @@ object RpcMethods { SUBAGENT_TAIL, PROCESS_LIST, PROCESS_KILL, + PLUGINS_MANAGE, + PROJECTS_LIST, ) } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt index e861aa1a..3a7dabd8 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt @@ -16,6 +16,9 @@ import org.junit.Assert.fail import org.junit.Test class PluginManageRepositoryTest { + private fun pluginRepo(handler: suspend (String, Map) -> Any?) = + PluginManageRepository(fakeCaller(handler)) + // CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list. @Test fun `listPlugins without profile calls PLUGINS_MANAGE action list and decodes plugins list`() = @@ -44,7 +47,7 @@ class PluginManageRepositoryTest { ) val repository = - PluginManageRepository { method, params -> + pluginRepo { method, params -> capturedMethod = method capturedParams = params mockPayload @@ -80,7 +83,7 @@ class PluginManageRepositoryTest { ) val repository = - PluginManageRepository { method, params -> + pluginRepo { method, params -> capturedMethod = method capturedParams = params mockPayload @@ -98,19 +101,19 @@ class PluginManageRepositoryTest { fun `listPlugins returns empty list when plugins key is missing or response is not a JsonObject`() = runTest { val emptyObjRepo = - PluginManageRepository { _, _ -> + pluginRepo { _, _ -> OkHttpProvider.json.parseToJsonElement("{}") } assertTrue(emptyObjRepo.listPlugins().isEmpty()) val nonObjRepo = - PluginManageRepository { _, _ -> + pluginRepo { _, _ -> OkHttpProvider.json.parseToJsonElement("[]") } assertTrue(nonObjRepo.listPlugins().isEmpty()) val nullRepo = - PluginManageRepository { _, _ -> + pluginRepo { _, _ -> null } assertTrue(nullRepo.listPlugins().isEmpty()) @@ -139,7 +142,7 @@ class PluginManageRepositoryTest { ) val repository = - PluginManageRepository { method, params -> + pluginRepo { method, params -> capturedMethod = method capturedParams = params mockResponse @@ -156,7 +159,7 @@ class PluginManageRepositoryTest { assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod) assertEquals("settings", capturedParams["action"]) assertEquals("camofox", capturedParams["key"]) - assertEquals(JsonObject(valuesToSave), capturedParams["values"]) + assertEquals(mapOf("port" to 9222, "headless" to true), capturedParams["values"]) assertFalse(capturedParams.containsKey("profile")) assertTrue(result.ok) @@ -186,7 +189,7 @@ class PluginManageRepositoryTest { ) val repository = - PluginManageRepository { method, params -> + pluginRepo { method, params -> capturedMethod = method capturedParams = params mockResponse @@ -204,7 +207,7 @@ class PluginManageRepositoryTest { mapOf( "action" to "settings", "key" to "camofox", - "values" to JsonObject(valuesToSave), + "values" to mapOf("port" to 8080), "profile" to "staging", ), capturedParams, @@ -222,7 +225,7 @@ class PluginManageRepositoryTest { fun `saveSettings throws SerializationException when response is malformed`() = runTest { val repository = - PluginManageRepository { _, _ -> + pluginRepo { _, _ -> OkHttpProvider.json.parseToJsonElement("{\"ok\": \"not-a-boolean\"}") } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt index 81a6d21f..1c1f6e50 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt @@ -9,13 +9,16 @@ import org.junit.Assert.assertTrue import org.junit.Test class PluginRemovalRepositoryTest { + private fun removalRepo(handler: suspend (String, Map) -> Any?) = + PluginRemovalRepository(fakeCaller(handler)) + @Test fun `remove sends scoped gateway action and decodes success`() = runTest { var method = "" var params: Map = emptyMap() val repository = - PluginRemovalRepository { requestedMethod, requestedParams -> + removalRepo { requestedMethod, requestedParams -> method = requestedMethod params = requestedParams Json.parseToJsonElement("""{"ok":true,"name":"demo"}""") @@ -33,11 +36,11 @@ class PluginRemovalRepositoryTest { @Test fun `refusal is a typed failure and missing ok cannot masquerade as success`() = runTest { - val refused = PluginRemovalRepository { _, _ -> mapOf("ok" to false, "error" to "bundled plugin") } + val refused = removalRepo { _, _ -> mapOf("ok" to false, "error" to "bundled plugin") } assertFalse(refused.remove("builtin").ok) assertEquals("bundled plugin", refused.remove("builtin").error) - val malformed = PluginRemovalRepository { _, _ -> mapOf("name" to "demo") } + val malformed = removalRepo { _, _ -> mapOf("name" to "demo") } try { malformed.remove("demo") throw AssertionError("Missing ok must fail decoding") diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ProjectsSourceTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ProjectsSourceTest.kt index f17be50b..a21f80aa 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/ProjectsSourceTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ProjectsSourceTest.kt @@ -8,12 +8,17 @@ import org.junit.Assert.assertTrue import org.junit.Test class ProjectsSourceTest { + private fun projectsSource( + connected: Boolean = true, + handler: suspend (String, Map) -> Any?, + ) = HermesProjectsSource(isConnected = { connected }, caller = fakeCaller(handler)) + @Test fun `decodes the untyped rpc map`() = runTest { var calledMethod: String? = null val source = - HermesProjectsSource(isConnected = { true }) { method, _ -> + projectsSource { method, _ -> calledMethod = method mapOf( "projects" to @@ -49,13 +54,10 @@ class ProjectsSourceTest { runTest { var calls = 0 val source = - HermesProjectsSource( - rpcRequest = { _, _ -> - calls++ - mapOf("projects" to emptyList()) - }, - isConnected = { false }, - ) + projectsSource(connected = false) { _, _ -> + calls++ + mapOf("projects" to emptyList()) + } assertNull(source.fetchProjects()) assertEquals("a disconnected fetch must not queue an RPC", 0, calls) @@ -65,10 +67,10 @@ class ProjectsSourceTest { fun `rpc failure or unexpected payload yields null`() = runTest { assertNull( - HermesProjectsSource(isConnected = { true }) { _, _ -> error("method not found") }.fetchProjects(), + projectsSource { _, _ -> error("method not found") }.fetchProjects(), ) - assertNull(HermesProjectsSource(isConnected = { true }) { _, _ -> "nope" }.fetchProjects()) - assertNull(HermesProjectsSource(isConnected = { true }) { _, _ -> null }.fetchProjects()) + assertNull(projectsSource { _, _ -> "nope" }.fetchProjects()) + assertNull(projectsSource { _, _ -> null }.fetchProjects()) } @Test @@ -76,9 +78,7 @@ class ProjectsSourceTest { runTest { val thrown = runCatching { - HermesProjectsSource( - isConnected = { true }, - ) { _, _ -> throw CancellationException("gone") }.fetchProjects() + projectsSource { _, _ -> throw CancellationException("gone") }.fetchProjects() }.exceptionOrNull() assertTrue(thrown is CancellationException) } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index c105d60e..6768ad61 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -318,10 +318,6 @@ class RawRpcParamsRatchetTest { listOf( "data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES", "data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS", - "data/ws/PluginManageRepository.kt::PLUGINS_MANAGE", - "data/ws/PluginManageRepository.kt::PLUGINS_MANAGE", - "data/ws/PluginRemovalRepository.kt::PLUGINS_MANAGE", - "data/ws/ProjectsSource.kt::PROJECTS_LIST", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/PluginsProjectsTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/PluginsProjectsTypesTest.kt new file mode 100644 index 00000000..8e1de470 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/PluginsProjectsTypesTest.kt @@ -0,0 +1,60 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PluginsProjectsTypesTest { + private fun encode(params: PluginsManageParams) = + OkHttpProvider.json.encodeToJsonElement(PluginsManageParams.serializer(), params) + + @Test + fun listEncodesOnlyAction() { + assertEquals(buildJsonObject { put("action", "list") }, encode(PluginsManageParams(action = "list"))) + } + + @Test + fun removeEncodesActionAndName() { + assertEquals( + buildJsonObject { + put("action", "remove") + put("name", "demo") + }, + encode(PluginsManageParams(action = "remove", name = "demo")), + ) + } + + @Test + fun settingsEncodesKeyValuesAndExplicitProfile() { + val values = buildJsonObject { put("port", JsonPrimitive(8080)) } + assertEquals( + buildJsonObject { + put("action", "settings") + put("profile", "staging") + put("key", "camofox") + put("values", values) + }, + encode(PluginsManageParams(action = "settings", key = "camofox", values = values, profile = "staging")), + ) + } + + @Test + fun projectsListEncodesAsEmptyObject() { + assertEquals( + buildJsonObject { }, + OkHttpProvider.json.encodeToJsonElement(ProjectsListParams.serializer(), ProjectsListParams), + ) + } + + @Test + fun descriptorsAreRegisteredWithContractNames() { + assertTrue(RpcMethods.PLUGINS_MANAGE in RpcMethods.all) + assertTrue(RpcMethods.PROJECTS_LIST in RpcMethods.all) + assertEquals("plugins.manage", RpcMethods.PLUGINS_MANAGE.name) + assertEquals("projects.list", RpcMethods.PROJECTS_LIST.name) + } +}