Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions devview-networkmock-core/api/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ package com.worldline.devview.networkmock.core.repository {
ctor public MockConfigRepository(java.util.List<java.lang.String> specPaths, com.worldline.devview.networkmock.core.NetworkMockResourceLoader resourceLoader);
method public suspend Object? discoverResponseFiles(com.worldline.devview.networkmock.core.model.OperationKey key, kotlin.coroutines.Continuation<? super java.util.List<com.worldline.devview.networkmock.core.model.MockResponse>>);
method public suspend Object? findMatchingMock(String host, String path, String method, optional java.util.Map<java.lang.String,? extends java.util.List<java.lang.String>> queryParameters, kotlin.coroutines.Continuation<? super com.worldline.devview.networkmock.core.model.MockMatch?>);
method public void invalidate();
method @KotlinOnly public suspend Object? loadConfiguration(kotlin.coroutines.Continuation<? super kotlin.Result<com.worldline.devview.networkmock.core.model.MockConfiguration>>);
method public suspend Object? loadMockResponse(com.worldline.devview.networkmock.core.model.OperationKey key, int statusCode, String exampleName, kotlin.coroutines.Continuation<? super com.worldline.devview.networkmock.core.model.MockResponse?>);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@ public class MockConfigRepository(
@Suppress("DocumentationOverPrivateProperty")
private var responseIndex: Map<String, Map<String, Map<Int, Map<String, String>>>> = 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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<String, String>
) : 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<String, String> = mapOf(
SPEC_PATH to baseSpecJson(),
"responses/getUser-200.json" to """{"id":1}""",
Expand Down Expand Up @@ -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<String, String> = mapOf(
SPEC_PATH to """
{
Expand Down
3 changes: 2 additions & 1 deletion devview-networkmock/api/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<kotlin.Unit> 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<kotlin.Unit> resetToNetworkSharedFlow, kotlinx.coroutines.flow.SharedFlow<kotlin.Unit> reloadConfigSharedFlow, com.worldline.devview.networkmock.viewmodel.NetworkMockViewModel viewModel, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.unit.Dp bottomPadding);
}

}
Expand Down Expand Up @@ -160,6 +160,7 @@ package com.worldline.devview.networkmock.viewmodel {
method @InaccessibleFromKotlin public kotlinx.coroutines.flow.StateFlow<com.worldline.devview.networkmock.viewmodel.OperationSheetState> getSheetState();
method @InaccessibleFromKotlin public kotlinx.coroutines.flow.StateFlow<com.worldline.devview.networkmock.viewmodel.NetworkMockUiState> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<NetworkMockUiState.Content>()
}

@Test
fun sheetState_isHidden_initially() = runTest {
val stateFlow = MutableStateFlow(NetworkMockState())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -128,6 +129,9 @@ public class NetworkMock(
override val destinations: PersistentMap<KClass<out NavKey>, 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)
}
Expand All @@ -149,6 +153,11 @@ public class NetworkMock(
onBufferOverflow = BufferOverflow.DROP_OLDEST
)

private val onReloadConfig = MutableSharedFlow<Unit>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)

override fun EntryProviderScope<NavKey>.registerContent(
onNavigateBack: () -> Unit,
onNavigate: (NavKey) -> Unit,
Expand All @@ -165,7 +174,8 @@ public class NetworkMock(
)
},
bottomPadding = bottomPadding,
resetToNetworkSharedFlow = onResetToNetwork
resetToNetworkSharedFlow = onResetToNetwork,
reloadConfigSharedFlow = onReloadConfig
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand All @@ -91,6 +95,7 @@ import kotlinx.coroutines.flow.SharedFlow
@Composable
public fun NetworkMockScreen(
resetToNetworkSharedFlow: SharedFlow<Unit>,
reloadConfigSharedFlow: SharedFlow<Unit>,
viewModel: NetworkMockViewModel,
modifier: Modifier = Modifier,
bottomPadding: Dp = 0.dp
Expand All @@ -104,6 +109,12 @@ public fun NetworkMockScreen(
}
}

LaunchedEffect(key1 = Unit) {
reloadConfigSharedFlow.collect {
viewModel.reloadConfiguration()
}
}

NetworkMockScreenContent(
uiState = uiState,
onGlobalToggle = viewModel::setGlobalMockingEnabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
10 changes: 10 additions & 0 deletions docs/modules/networkmock-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
1 change: 1 addition & 0 deletions docs/modules/networkmock-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions docs/modules/networkmock-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading