From 2ccd77766e98f5fb62d220e6a5fd8d5f212fcd88 Mon Sep 17 00:00:00 2001 From: Maxime MICHEL Date: Tue, 22 Sep 2026 11:37:39 +0200 Subject: [PATCH] :sparkles: Reload networkmock config without restarting the app MockConfigRepository caches the parsed OpenAPI config forever after the first successful load, with no way to invalidate it - a developer editing a spec file had to restart the app to see the change. - MockConfigRepository.invalidate(): clears the cached config and response index, forcing the next loadConfiguration() to re-read and re-parse every spec from scratch. - NetworkMockViewModel.reloadConfiguration(): invalidates then reloads, re-registering the (possibly changed) set of operations. - A new 'Reload Config' toolbar action in NetworkMock, wired the same way as the existing 'Reset to Network' action (SharedFlow -> LaunchedEffect -> ViewModel call). Operations added, removed, or renamed in the spec appear immediately after reloading; persisted per-operation mock selections are untouched. Closes #90. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 9 ++ devview-networkmock-core/api/api.txt | 1 + .../core/repository/MockConfigRepository.kt | 14 +++ .../repository/MockConfigRepositoryTest.kt | 104 ++++++++++++++++++ devview-networkmock/api/api.txt | 3 +- .../viewmodel/NetworkMockViewModelTest.kt | 22 ++++ .../devview/networkmock/NetworkMock.kt | 12 +- .../devview/networkmock/NetworkMockScreen.kt | 11 ++ .../viewmodel/NetworkMockViewModel.kt | 15 +++ docs/modules/networkmock-core.md | 10 ++ docs/modules/networkmock-ui.md | 1 + docs/modules/networkmock-workflows.md | 10 ++ 12 files changed, 210 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb4f5c5c..1a1096f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- NetworkMock: a "Reload Config" toolbar action, and `MockConfigRepository.invalidate()` / + `NetworkMockViewModel.reloadConfiguration()`, to re-read and re-parse the configured OpenAPI + specs without restarting the app — previously the parsed config was cached forever after the + first load, with no way to pick up an edited spec file short of a process restart. Operations + added, removed, or renamed in the spec appear immediately after reloading; persisted + per-operation mock selections are untouched. (`devview-networkmock-core`, + `devview-networkmock`, #90) + ### Fixed - NetworkMock: replaced ~35 unconditional `println` calls in `MockConfigRepository` and `NetworkMockPlugin` with gated [Kermit](https://github.com/touchlab/Kermit) logging diff --git a/devview-networkmock-core/api/api.txt b/devview-networkmock-core/api/api.txt index 756b46a2..1be31938 100644 --- a/devview-networkmock-core/api/api.txt +++ b/devview-networkmock-core/api/api.txt @@ -237,6 +237,7 @@ package com.worldline.devview.networkmock.core.repository { ctor public MockConfigRepository(java.util.List specPaths, com.worldline.devview.networkmock.core.NetworkMockResourceLoader resourceLoader); method public suspend Object? discoverResponseFiles(com.worldline.devview.networkmock.core.model.OperationKey key, kotlin.coroutines.Continuation>); method public suspend Object? findMatchingMock(String host, String path, String method, optional java.util.Map> queryParameters, kotlin.coroutines.Continuation); + method public void invalidate(); method @KotlinOnly public suspend Object? loadConfiguration(kotlin.coroutines.Continuation>); method public suspend Object? loadMockResponse(com.worldline.devview.networkmock.core.model.OperationKey key, int statusCode, String exampleName, kotlin.coroutines.Continuation); } diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepository.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepository.kt index 4004deb2..87937f58 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepository.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepository.kt @@ -48,6 +48,20 @@ public class MockConfigRepository( @Suppress("DocumentationOverPrivateProperty") private var responseIndex: Map>>> = emptyMap() + /** + * Clears the cached configuration, forcing the next [loadConfiguration] call to re-read + * and re-parse every configured spec from scratch. + * + * Use this to pick up edits to a spec file without restarting the app/process — call this, + * then [loadConfiguration] (or anything that calls it internally, e.g. [findMatchingMock]) + * to actually reload. + */ + public fun invalidate() { + cachedConfig = null + responseIndex = emptyMap() + logger.d { "Configuration cache invalidated" } + } + /** * Loads and parses every configured OpenAPI spec. * diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt index 36a4862d..ce57becc 100644 --- a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt @@ -40,6 +40,35 @@ class MockConfigRepositoryTest { loader.callCount(path = SPEC_PATH) shouldBe 1 } + @Test + fun `invalidate forces loadConfiguration to re-read the spec file`() = runTest { + val loader = RecordingResourceLoader(resources = baseResources()) + val repository = MockConfigRepository(specPaths = listOf(SPEC_PATH), resourceLoader = loader) + + repository.loadConfiguration().getOrThrow() + repository.invalidate() + repository.loadConfiguration().getOrThrow() + + loader.callCount(path = SPEC_PATH) shouldBe 2 + } + + @Test + fun `invalidate then loadConfiguration reflects a changed spec file`() = runTest { + val loader = MutableResourceLoader(resources = baseResources()) + val repository = MockConfigRepository(specPaths = listOf(SPEC_PATH), resourceLoader = loader) + + val before = repository.loadConfiguration().getOrThrow() + before.specs[0].operations.map { it.operationId } shouldContainExactly + listOf("getUser", "createUser") + + loader.replace(path = SPEC_PATH, content = specJsonWithDeleteUserAdded()) + repository.invalidate() + val after = repository.loadConfiguration().getOrThrow() + + after.specs[0].operations.map { it.operationId } shouldContainExactly + listOf("getUser", "deleteUser", "createUser") + } + @Test fun `loadConfiguration returns failure when spec file is missing`() = runTest { val repository = createRepository(resources = emptyMap()) @@ -551,6 +580,20 @@ class MockConfigRepositoryTest { fun callCount(path: String): Int = calls[path] ?: 0 } + /** Like [RecordingResourceLoader], but [replace] lets a test simulate an edited spec file. */ + private class MutableResourceLoader( + resources: Map + ) : NetworkMockResourceLoader { + private val resources = resources.toMutableMap() + + override suspend fun load(path: String): ByteArray = + resources[path]?.encodeToByteArray() ?: error("Resource not found: $path") + + fun replace(path: String, content: String) { + resources[path] = content + } + } + private fun baseResources(): Map = mapOf( SPEC_PATH to baseSpecJson(), "responses/getUser-200.json" to """{"id":1}""", @@ -613,6 +656,67 @@ class MockConfigRepositoryTest { } """.trimIndent() + /** [baseSpecJson] with a `deleteUser` operation added — simulates an edited spec file. */ + private fun specJsonWithDeleteUserAdded(): String = """ + { + "info": { "title": "Example" }, + "servers": [ + { "url": "https://staging.api.example.com:8443/v1" }, + { "url": "https://api.example.com" } + ], + "paths": { + "/api/users/{userId}": { + "get": { + "operationId": "getUser", + "summary": "Get User", + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "default": { "externalValue": "/responses/getUser-200.json" } + } + } + } + }, + "404": { + "content": { + "application/json": { + "examples": { + "default": { "externalValue": "/responses/getUser-404.json" } + } + } + } + } + } + }, + "delete": { + "operationId": "deleteUser", + "summary": "Delete User", + "responses": {} + } + }, + "/api/users": { + "post": { + "operationId": "createUser", + "summary": "Create User", + "responses": { + "201": { + "content": { + "application/json": { + "examples": { + "default": { "externalValue": "/responses/createUser-201.json" } + } + } + } + } + } + } + } + } + } + """.trimIndent() + private fun multiExampleResources(): Map = mapOf( SPEC_PATH to """ { diff --git a/devview-networkmock/api/api.txt b/devview-networkmock/api/api.txt index b578b48e..57c29a0e 100644 --- a/devview-networkmock/api/api.txt +++ b/devview-networkmock/api/api.txt @@ -27,7 +27,7 @@ package com.worldline.devview.networkmock { } public final class NetworkMockScreenKt { - method @KotlinOnly @androidx.compose.runtime.Composable public static void NetworkMockScreen(kotlinx.coroutines.flow.SharedFlow resetToNetworkSharedFlow, com.worldline.devview.networkmock.viewmodel.NetworkMockViewModel viewModel, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.unit.Dp bottomPadding); + method @KotlinOnly @androidx.compose.runtime.Composable public static void NetworkMockScreen(kotlinx.coroutines.flow.SharedFlow resetToNetworkSharedFlow, kotlinx.coroutines.flow.SharedFlow reloadConfigSharedFlow, com.worldline.devview.networkmock.viewmodel.NetworkMockViewModel viewModel, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.unit.Dp bottomPadding); } } @@ -160,6 +160,7 @@ package com.worldline.devview.networkmock.viewmodel { method @InaccessibleFromKotlin public kotlinx.coroutines.flow.StateFlow getSheetState(); method @InaccessibleFromKotlin public kotlinx.coroutines.flow.StateFlow getUiState(); method public void openOperation(com.worldline.devview.networkmock.core.model.OperationKey key); + method public void reloadConfiguration(); method public void resetAllToNetwork(); method public void setGlobalMockingEnabled(boolean enabled); method public void setOperationMockState(com.worldline.devview.networkmock.core.model.OperationKey key, com.worldline.devview.networkmock.core.model.MockResponse? response); diff --git a/devview-networkmock/src/androidHostTest/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModelTest.kt b/devview-networkmock/src/androidHostTest/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModelTest.kt index d7de64da..98749af5 100644 --- a/devview-networkmock/src/androidHostTest/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModelTest.kt +++ b/devview-networkmock/src/androidHostTest/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModelTest.kt @@ -21,6 +21,7 @@ import io.kotest.matchers.types.shouldBeInstanceOf import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -223,6 +224,27 @@ class NetworkMockViewModelTest : ViewModelTest() { allNetwork[OperationKey("catalog-api", "getProduct")] shouldBe OperationMockState.Network } + @Test + fun reloadConfiguration_invalidatesRepositoryThenReloads() = runTest { + val stateFlow = MutableStateFlow(NetworkMockState()) + val configRepository = + createConfigRepositoryMock(loadResult = Result.success(testConfiguration())) + val stateRepository = createStateRepositoryMock(stateFlow) + every { configRepository.invalidate() } just Runs + + val viewModel = NetworkMockViewModel(configRepository, stateRepository) + collectState(viewModel.uiState) + + viewModel.reloadConfiguration() + + coVerifyOrder { + configRepository.loadConfiguration() + configRepository.invalidate() + configRepository.loadConfiguration() + } + viewModel.uiState.value.shouldBeInstanceOf() + } + @Test fun sheetState_isHidden_initially() = runTest { val stateFlow = MutableStateFlow(NetworkMockState()) diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMock.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMock.kt index 513bd2ef..3773cd5e 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMock.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMock.kt @@ -2,6 +2,7 @@ package com.worldline.devview.networkmock import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Restore import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -128,6 +129,9 @@ public class NetworkMock( override val destinations: PersistentMap, DestinationMetadata> = persistentMapOf( NetworkMockDestination.Main.withTitle(title = "Network Mock") { + action(icon = Icons.Rounded.Refresh) { + onReloadConfig.tryEmit(value = Unit) + } action(icon = Icons.Rounded.Restore) { onResetToNetwork.tryEmit(value = Unit) } @@ -149,6 +153,11 @@ public class NetworkMock( onBufferOverflow = BufferOverflow.DROP_OLDEST ) + private val onReloadConfig = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + override fun EntryProviderScope.registerContent( onNavigateBack: () -> Unit, onNavigate: (NavKey) -> Unit, @@ -165,7 +174,8 @@ public class NetworkMock( ) }, bottomPadding = bottomPadding, - resetToNetworkSharedFlow = onResetToNetwork + resetToNetworkSharedFlow = onResetToNetwork, + reloadConfigSharedFlow = onReloadConfig ) } } diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt index ba18ff40..385ed9a7 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt @@ -78,9 +78,13 @@ import kotlinx.coroutines.flow.SharedFlow * - Enable/disable individual endpoint mocks * - Select which mock response to return for each endpoint, via [NetworkMockOperationSheet] * - Reset all mocks to use actual network + * - Reload the configured OpenAPI specs from disk, picking up edits without an app restart * * @param resetToNetworkSharedFlow Shared flow emitted by [NetworkMock] when the user triggers * the "Reset to Network" toolbar action. Collected here to call [NetworkMockViewModel.resetAllToNetwork]. + * @param reloadConfigSharedFlow Shared flow emitted by [NetworkMock] when the user triggers the + * "Reload Config" toolbar action. Collected here to call [NetworkMockViewModel.reloadConfiguration], + * picking up edits to a spec file without restarting the app. * @param viewModel The [NetworkMockViewModel] instance. Constructed and provided by * [NetworkMock.registerContent] via the `viewModel { }` factory so that it is scoped to the * navigation entry. Also owns the operation sheet's state — see [NetworkMockViewModel.sheetState]. @@ -91,6 +95,7 @@ import kotlinx.coroutines.flow.SharedFlow @Composable public fun NetworkMockScreen( resetToNetworkSharedFlow: SharedFlow, + reloadConfigSharedFlow: SharedFlow, viewModel: NetworkMockViewModel, modifier: Modifier = Modifier, bottomPadding: Dp = 0.dp @@ -104,6 +109,12 @@ public fun NetworkMockScreen( } } + LaunchedEffect(key1 = Unit) { + reloadConfigSharedFlow.collect { + viewModel.reloadConfiguration() + } + } + NetworkMockScreenContent( uiState = uiState, onGlobalToggle = viewModel::setGlobalMockingEnabled, diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModel.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModel.kt index a80ef308..b09c0eaa 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModel.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockViewModel.kt @@ -209,6 +209,21 @@ public class NetworkMockViewModel( privateSheetError.value = null } + /** + * Re-reads and re-parses every configured OpenAPI spec, picking up edits made to a spec + * file since the app started without requiring a restart. + * + * Invalidates [configRepository]'s cache, then re-runs [loadConfiguration] — operations + * added, removed, or renamed in the spec are reflected in [uiState] once this completes. + * The operation picker/preview sheet ([sheetState]) is unaffected by this call; if it's + * open for an operation that no longer exists, it keeps showing its last-loaded content + * until closed. + */ + public fun reloadConfiguration() { + configRepository.invalidate() + loadConfiguration() + } + /** * Loads the mock configuration from the configured OpenAPI specs. * diff --git a/docs/modules/networkmock-core.md b/docs/modules/networkmock-core.md index caff5f66..873c7adc 100644 --- a/docs/modules/networkmock-core.md +++ b/docs/modules/networkmock-core.md @@ -118,6 +118,16 @@ paths: delayMs: 500 # per-operation — overrides the document default ``` +## Caching & Reload + +`MockConfigRepository` parses every configured spec once and caches the result — subsequent +`loadConfiguration()` calls return the cached value without re-reading any file. Call +`invalidate()` to clear that cache, then `loadConfiguration()` (or anything that calls it +internally, like `findMatchingMock`) to force a fresh read — this is how a developer picks up +an edited spec file without restarting the app. In `devview-networkmock`, the "Reload Config" +toolbar action does exactly this via `NetworkMockViewModel.reloadConfiguration()`; see +[NetworkMock UI](networkmock-ui.md). + ## DataStore Schema State is persisted via `MockStateRepository`: diff --git a/docs/modules/networkmock-ui.md b/docs/modules/networkmock-ui.md index ee0c26f7..a58e8789 100644 --- a/docs/modules/networkmock-ui.md +++ b/docs/modules/networkmock-ui.md @@ -16,6 +16,7 @@ The main screen shows a global mock toggle at the top, followed by a scrollable - **Method filter**: a per-tab row of chips, one per distinct `Operation.method` present among that tab's operations, ordered `GET`/`POST`/`PUT`/`PATCH`/`DELETE`/`HEAD`/`OPTIONS`. Multi-select — no chip selected shows every method; selecting one or more narrows the list to operations using any of the selected methods. - **Endpoint rows**: a leading colour rail (the state chip's colour, transparent when not mocked) followed by the endpoint name, a colour-coded HTTP method badge, the path (wraps instead of truncating — never cut off), and the current state chip (Network / bare status code, e.g. "404"). There is no separate version badge — `Operation.version` is parsed from the path shown right next to it, so it would only repeat what's already visible. Tap a row to open its operation sheet. - **Reset to Network**: toolbar action that resets every endpoint to `Network` state in one tap. +- **Reload Config**: toolbar action that re-reads and re-parses every configured OpenAPI spec from disk, picking up edits to a spec file without restarting the app. Operations added, removed, or renamed in the spec appear immediately; per-operation mock selections are untouched. See [Caching & Reload](networkmock-core.md#caching--reload). ### Operation sheet diff --git a/docs/modules/networkmock-workflows.md b/docs/modules/networkmock-workflows.md index ea155614..a4e20b03 100644 --- a/docs/modules/networkmock-workflows.md +++ b/docs/modules/networkmock-workflows.md @@ -119,6 +119,16 @@ paths: - **UI**: Open DevView → Network Mock → tap the restore icon in the top toolbar. - **All mocks are reset to `Network` state**, including operations the user has never explicitly touched. +## Reloading a spec after editing it + +Editing a spec file on disk (adding an operation, changing a response example) isn't picked up +automatically — `MockConfigRepository` caches the parsed spec after the first load. + +- **UI**: Open DevView → Network Mock → tap the refresh icon in the top toolbar. +- The spec is re-read and re-parsed from scratch; operations added, removed, or renamed appear + immediately. Per-operation mock selections already stored in DataStore are untouched. +- No app restart required. + ## Related Modules - [NetworkMock](networkmock.md): Overview and installation.