fix(#1402): replace Map<String, Any> Retrofit types with typed DTOs (#1403)

This commit is contained in:
m57
2026-09-30 12:35:01 +03:00
committed by GitHub
parent 7a2822fade
commit fc2ce3f26b
9 changed files with 203 additions and 30 deletions
@@ -0,0 +1,11 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
/** Body for POST api/credentials/pool. `apiKey` serializes as `api_key`. */
@Serializable
data class CredentialPoolAddRequest(
val provider: String,
val apiKey: String,
val label: String? = null,
)
@@ -9,3 +9,8 @@ data class DebugShareResponse(
val redacted: Boolean? = null, val redacted: Boolean? = null,
val auto_delete_seconds: Long? = null, val auto_delete_seconds: Long? = null,
) )
@Serializable
data class DebugShareRequest(
val redact: Boolean? = null,
)
@@ -17,3 +17,19 @@ data class HookEntry(
val approved_at: String? = null, val approved_at: String? = null,
val executable: Boolean? = null, val executable: Boolean? = null,
) )
/** Body for POST api/ops/hooks. The backend field is `approve` (writes the consent allowlist entry). */
@Serializable
data class HookCreateRequest(
val event: String,
val command: String,
val matcher: String? = null,
val timeout: Int? = null,
val approve: Boolean? = null,
)
@Serializable
data class HookDeleteRequest(
val event: String,
val command: String,
)
@@ -96,3 +96,9 @@ data class McpOAuthFlowResponse(
val authorizationUrl: String? = null, val authorizationUrl: String? = null,
val error: String? = null, val error: String? = null,
) )
/** Body for PUT api/mcp/servers/{name}; only the fields being changed are sent. */
@Serializable
data class McpServerUpdateRequest(
val env: Map<String, String>? = null,
)
@@ -20,10 +20,12 @@ import com.m57.hermescontrol.data.model.CreateCronJobRequest
import com.m57.hermescontrol.data.model.CreateProfileRequest import com.m57.hermescontrol.data.model.CreateProfileRequest
import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateWebhookRequest import com.m57.hermescontrol.data.model.CreateWebhookRequest
import com.m57.hermescontrol.data.model.CredentialPoolAddRequest
import com.m57.hermescontrol.data.model.CredentialPoolResponse import com.m57.hermescontrol.data.model.CredentialPoolResponse
import com.m57.hermescontrol.data.model.CronBlueprintListResponse import com.m57.hermescontrol.data.model.CronBlueprintListResponse
import com.m57.hermescontrol.data.model.CronJob import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.model.CuratorResponse import com.m57.hermescontrol.data.model.CuratorResponse
import com.m57.hermescontrol.data.model.DebugShareRequest
import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DebugShareResponse
import com.m57.hermescontrol.data.model.DeleteWebhookResponse import com.m57.hermescontrol.data.model.DeleteWebhookResponse
import com.m57.hermescontrol.data.model.DeliveryTargetsResponse import com.m57.hermescontrol.data.model.DeliveryTargetsResponse
@@ -38,6 +40,8 @@ import com.m57.hermescontrol.data.model.EnvVarUpdate
import com.m57.hermescontrol.data.model.GatewayMigrationPlan import com.m57.hermescontrol.data.model.GatewayMigrationPlan
import com.m57.hermescontrol.data.model.GatewayMigrationStartResponse import com.m57.hermescontrol.data.model.GatewayMigrationStartResponse
import com.m57.hermescontrol.data.model.HealthStatus import com.m57.hermescontrol.data.model.HealthStatus
import com.m57.hermescontrol.data.model.HookCreateRequest
import com.m57.hermescontrol.data.model.HookDeleteRequest
import com.m57.hermescontrol.data.model.HookResponse import com.m57.hermescontrol.data.model.HookResponse
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardResponse
@@ -57,6 +61,7 @@ import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.data.model.McpServerTestResponse import com.m57.hermescontrol.data.model.McpServerTestResponse
import com.m57.hermescontrol.data.model.McpServerToggleRequest import com.m57.hermescontrol.data.model.McpServerToggleRequest
import com.m57.hermescontrol.data.model.McpServerUpdateRequest
import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.model.McpServersResponse
import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse
import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateRequest import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateRequest
@@ -583,7 +588,7 @@ interface HermesApiService : KanbanApiService {
@PUT("api/mcp/servers/{name}") @PUT("api/mcp/servers/{name}")
suspend fun updateMcpServer( suspend fun updateMcpServer(
@Path("name") name: String, @Path("name") name: String,
@Body body: Map<String, Any>, @Body body: McpServerUpdateRequest,
): Response<McpServer> ): Response<McpServer>
@POST("api/mcp/servers/{name}/auth") @POST("api/mcp/servers/{name}/auth")
@@ -602,7 +607,7 @@ interface HermesApiService : KanbanApiService {
@POST("api/mcp/catalog/install") @POST("api/mcp/catalog/install")
suspend fun installMcpCatalogEntry( suspend fun installMcpCatalogEntry(
@Body body: McpCatalogInstallRequest, @Body body: McpCatalogInstallRequest,
): Response<Map<String, Any>> ): Response<ActionResponse>
@GET("api/webhooks") @GET("api/webhooks")
suspend fun getWebhooks(): Response<WebhooksResponse> suspend fun getWebhooks(): Response<WebhooksResponse>
@@ -881,7 +886,7 @@ interface HermesApiService : KanbanApiService {
@PUT("api/curator/paused") @PUT("api/curator/paused")
suspend fun setCuratorPaused( suspend fun setCuratorPaused(
@Body body: Map<String, Boolean>, @Body body: Map<String, Boolean>,
): Response<Map<String, Any>> ): Response<ActionResponse>
@POST("api/curator/run") @POST("api/curator/run")
suspend fun runCurator(): Response<ActionResponse> suspend fun runCurator(): Response<ActionResponse>
@@ -918,14 +923,14 @@ interface HermesApiService : KanbanApiService {
@POST("api/credentials/pool") @POST("api/credentials/pool")
suspend fun addCredentialPoolEntry( suspend fun addCredentialPoolEntry(
@Body body: Map<String, String>, @Body body: CredentialPoolAddRequest,
): Response<Map<String, Any>> ): Response<ActionResponse>
@DELETE("api/credentials/pool/{provider}/{index}") @DELETE("api/credentials/pool/{provider}/{index}")
suspend fun removeCredentialPoolEntry( suspend fun removeCredentialPoolEntry(
@Path("provider") provider: String, @Path("provider") provider: String,
@Path("index") index: Int, @Path("index") index: Int,
): Response<Map<String, Any>> ): Response<ActionResponse>
// ── Admin: Operations ───────────────────────────────────────────── // ── Admin: Operations ─────────────────────────────────────────────
@POST("api/ops/security-audit") @POST("api/ops/security-audit")
@@ -968,7 +973,7 @@ interface HermesApiService : KanbanApiService {
// ── Admin: Debug share ──────────────────────────────────────────── // ── Admin: Debug share ────────────────────────────────────────────
@POST("api/ops/debug-share") @POST("api/ops/debug-share")
suspend fun runDebugShare( suspend fun runDebugShare(
@Body body: Map<String, Any>, @Body body: DebugShareRequest,
): Response<DebugShareResponse> ): Response<DebugShareResponse>
// ── Admin: Checkpoints ──────────────────────────────────────────── // ── Admin: Checkpoints ────────────────────────────────────────────
@@ -984,13 +989,13 @@ interface HermesApiService : KanbanApiService {
@POST("api/ops/hooks") @POST("api/ops/hooks")
suspend fun createHook( suspend fun createHook(
@Body body: Map<String, Any>, @Body body: HookCreateRequest,
): Response<Map<String, Any>> ): Response<ActionResponse>
@DELETE("api/ops/hooks") @HTTP(method = "DELETE", path = "api/ops/hooks", hasBody = true)
suspend fun deleteHook( suspend fun deleteHook(
@Body body: Map<String, String>, @Body body: HookDeleteRequest,
): Response<Map<String, Any>> ): Response<ActionResponse>
// ── Admin: Action status (log viewer) ──────────────────────────── // ── Admin: Action status (log viewer) ────────────────────────────
@GET("api/actions/{name}/status") @GET("api/actions/{name}/status")
@@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.data.model.McpServerTestResponse import com.m57.hermescontrol.data.model.McpServerTestResponse
import com.m57.hermescontrol.data.model.McpServerToggleRequest import com.m57.hermescontrol.data.model.McpServerToggleRequest
import com.m57.hermescontrol.data.model.McpServerUpdateRequest
import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.model.McpServersResponse
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkError
@@ -458,7 +459,12 @@ class McpServersViewModel(
val updatedEnv = existingEnv + (key to value) val updatedEnv = existingEnv + (key to value)
val result = val result =
withContext(ioDispatcher) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) } safeApiCall {
ApiClient.hermesApi.updateMcpServer(
serverName,
McpServerUpdateRequest(env = updatedEnv),
)
}
} }
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
@@ -490,7 +496,12 @@ class McpServersViewModel(
val updatedEnv = existingEnv - key val updatedEnv = existingEnv - key
val result = val result =
withContext(ioDispatcher) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) } safeApiCall {
ApiClient.hermesApi.updateMcpServer(
serverName,
McpServerUpdateRequest(env = updatedEnv),
)
}
} }
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
@@ -11,11 +11,15 @@ import com.m57.hermescontrol.data.model.ActionResponse
import com.m57.hermescontrol.data.model.ActionStatusResponse import com.m57.hermescontrol.data.model.ActionStatusResponse
import com.m57.hermescontrol.data.model.BackupTriggerRequest import com.m57.hermescontrol.data.model.BackupTriggerRequest
import com.m57.hermescontrol.data.model.CheckpointsResponse import com.m57.hermescontrol.data.model.CheckpointsResponse
import com.m57.hermescontrol.data.model.CredentialPoolAddRequest
import com.m57.hermescontrol.data.model.CredentialPoolProvider import com.m57.hermescontrol.data.model.CredentialPoolProvider
import com.m57.hermescontrol.data.model.CuratorResponse import com.m57.hermescontrol.data.model.CuratorResponse
import com.m57.hermescontrol.data.model.DebugShareRequest
import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DebugShareResponse
import com.m57.hermescontrol.data.model.DoctorResponse import com.m57.hermescontrol.data.model.DoctorResponse
import com.m57.hermescontrol.data.model.GatewayMigrationPlan import com.m57.hermescontrol.data.model.GatewayMigrationPlan
import com.m57.hermescontrol.data.model.HookCreateRequest
import com.m57.hermescontrol.data.model.HookDeleteRequest
import com.m57.hermescontrol.data.model.HookResponse import com.m57.hermescontrol.data.model.HookResponse
import com.m57.hermescontrol.data.model.PortalResponse import com.m57.hermescontrol.data.model.PortalResponse
import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.StatusResponse
@@ -531,11 +535,11 @@ class SystemViewModel(
_uiState.update { it.copy(addingCred = true) } _uiState.update { it.copy(addingCred = true) }
viewModelScope.launch { viewModelScope.launch {
val body = val body =
buildMap { CredentialPoolAddRequest(
put("provider", state.credProvider) provider = state.credProvider,
put("token", state.credKey) apiKey = state.credKey,
if (state.credLabel.isNotBlank()) put("label", state.credLabel) label = state.credLabel.ifBlank { null },
} )
val result = val result =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.addCredentialPoolEntry(body) } safeApiCall { ApiClient.hermesApi.addCredentialPoolEntry(body) }
@@ -780,7 +784,7 @@ class SystemViewModel(
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.runDebugShare(mapOf("redact" to shareRedact)) } safeApiCall { ApiClient.hermesApi.runDebugShare(DebugShareRequest(redact = shareRedact)) }
} }
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
@@ -849,13 +853,13 @@ class SystemViewModel(
_uiState.update { it.copy(creatingHook = true) } _uiState.update { it.copy(creatingHook = true) }
viewModelScope.launch { viewModelScope.launch {
val body = val body =
buildMap<String, Any> { HookCreateRequest(
put("event", state.hookEvent) event = state.hookEvent,
put("command", state.hookCommand) command = state.hookCommand,
if (state.hookMatcher.isNotBlank()) put("matcher", state.hookMatcher) matcher = state.hookMatcher.ifBlank { null },
if (state.hookTimeout.isNotBlank()) put("timeout", state.hookTimeout.toIntOrNull() ?: 30) timeout = if (state.hookTimeout.isBlank()) null else state.hookTimeout.toIntOrNull() ?: 30,
put("allowed", state.hookApprove) approve = state.hookApprove,
} )
val result = val result =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.createHook(body) } safeApiCall { ApiClient.hermesApi.createHook(body) }
@@ -894,7 +898,7 @@ class SystemViewModel(
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.deleteHook(mapOf("event" to event, "command" to command)) } safeApiCall { ApiClient.hermesApi.deleteHook(HookDeleteRequest(event, command)) }
} }
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
@@ -0,0 +1,114 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.model.CredentialPoolAddRequest
import com.m57.hermescontrol.data.model.DebugShareRequest
import com.m57.hermescontrol.data.model.HookCreateRequest
import com.m57.hermescontrol.data.model.HookDeleteRequest
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
import com.m57.hermescontrol.data.model.McpServerUpdateRequest
import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
/**
* Regression for #1402: `Map<String, Any>` bodies/results have no kotlinx serializer, so Retrofit failed to
* create the converter before any request was sent. These call the real service methods so converter creation
* (which JSON round-trip tests never exercise) is covered.
*/
class HermesApiServiceConverterTest {
private lateinit var server: MockWebServer
private lateinit var api: HermesApiService
@Before
fun setUp() {
server = MockWebServer().also { it.start() }
api =
Retrofit
.Builder()
.baseUrl(server.url("/"))
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
.build()
.create(HermesApiService::class.java)
}
@After
fun tearDown() {
server.shutdown()
}
private fun ok(body: String = """{"ok":true}""") =
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(body))
@Test
fun catalogInstall_readsResponse() =
runBlocking {
ok("""{"ok":true,"name":"example","background":false}""")
val r = api.installMcpCatalogEntry(McpCatalogInstallRequest(name = "example"))
assertTrue(r.isSuccessful)
assertEquals("example", r.body()?.name)
assertEquals("/api/mcp/catalog/install", server.takeRequest().path)
}
@Test
fun updateMcpServer_sendsEnvBody() =
runBlocking {
ok("""{"name":"s","enabled":true}""")
api.updateMcpServer("s", McpServerUpdateRequest(env = mapOf("K" to "v")))
val req = server.takeRequest()
assertEquals("PUT", req.method)
assertEquals("""{"env":{"K":"v"}}""", req.body.readUtf8())
}
@Test
fun curatorPaused_readsResponse() =
runBlocking {
ok("""{"ok":true,"paused":true}""")
assertTrue(api.setCuratorPaused(mapOf("paused" to true)).isSuccessful)
assertEquals("""{"paused":true}""", server.takeRequest().body.readUtf8())
}
@Test
fun credentialPool_addSendsApiKeyField_removeReadsResponse() =
runBlocking {
ok("""{"ok":true,"provider":"openrouter","count":1}""")
assertTrue(api.addCredentialPoolEntry(CredentialPoolAddRequest("openrouter", "sk", "lbl")).isSuccessful)
assertEquals(
"""{"provider":"openrouter","api_key":"sk","label":"lbl"}""",
server.takeRequest().body.readUtf8(),
)
ok("""{"ok":true,"provider":"openrouter","count":0,"cleaned":[],"hints":[]}""")
assertTrue(api.removeCredentialPoolEntry("openrouter", 1).isSuccessful)
}
@Test
fun hooks_createSendsApprove_deleteSendsBody() =
runBlocking {
ok("""{"ok":true,"event":"e","command":"c","approved":true}""")
assertTrue(api.createHook(HookCreateRequest("e", "c", timeout = 5, approve = true)).isSuccessful)
assertEquals(
"""{"event":"e","command":"c","timeout":5,"approve":true}""",
server.takeRequest().body.readUtf8(),
)
ok()
assertTrue(api.deleteHook(HookDeleteRequest("e", "c")).isSuccessful)
val del = server.takeRequest()
assertEquals("DELETE", del.method)
assertEquals("""{"event":"e","command":"c"}""", del.body.readUtf8())
}
@Test
fun debugShare_sendsRedact() =
runBlocking {
ok("""{"ok":true,"urls":{"report":"u"}}""")
assertTrue(api.runDebugShare(DebugShareRequest(redact = false)).isSuccessful)
assertEquals("""{"redact":false}""", server.takeRequest().body.readUtf8())
}
}
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.mcp
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.data.model.McpServerTestResponse import com.m57.hermescontrol.data.model.McpServerTestResponse
import com.m57.hermescontrol.data.model.McpServerUpdateRequest
import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.model.McpServersResponse
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService import com.m57.hermescontrol.data.remote.HermesApiService
@@ -122,10 +123,10 @@ class McpOwnershipTest {
vm.updateEnvValue("value") vm.updateEnvValue("value")
vm.addEnvVar(config.name) vm.addEnvVar(config.name)
dispatcher.scheduler.advanceUntilIdle() dispatcher.scheduler.advanceUntilIdle()
coVerify { api.updateMcpServer(config.name, mapOf("env" to mapOf("KEY" to "value"))) } coVerify { api.updateMcpServer(config.name, McpServerUpdateRequest(env = mapOf("KEY" to "value"))) }
vm.removeEnvVar(config.name, "KEY") vm.removeEnvVar(config.name, "KEY")
dispatcher.scheduler.advanceUntilIdle() dispatcher.scheduler.advanceUntilIdle()
coVerify { api.updateMcpServer(config.name, mapOf("env" to emptyMap<String, String>())) } coVerify { api.updateMcpServer(config.name, McpServerUpdateRequest(env = emptyMap())) }
coEvery { api.deleteMcpServer(config.name) } returns Response.success(Unit) coEvery { api.deleteMcpServer(config.name) } returns Response.success(Unit)
vm.deleteServer(config.name) vm.deleteServer(config.name)
dispatcher.scheduler.advanceUntilIdle() dispatcher.scheduler.advanceUntilIdle()