diff --git a/TODO-endpoint-preview.md b/TODO-endpoint-preview.md new file mode 100644 index 0000000..b54d4ed --- /dev/null +++ b/TODO-endpoint-preview.md @@ -0,0 +1,243 @@ +# NetworkMock — Endpoint Detail Screen & Mock Preview + +Migration of `NetworkMockEndpointBottomSheet` to a proper full-screen navigation +destination, enabling a `ModalBottomSheet` for previewing individual mock response +content without any sheet-nesting concerns. + +--- + +## Current State (as of 2026-03-13) + +| File | Status | +|---|---| +| `NetworkMock.kt` | ✅ `Endpoint` destination declared, serializer registered, `entryDestination` set, `entry` fully wired with inline VM factory | +| `NetworkMockScreen.kt` | Old bottom-sheet block **commented out**; `selectedDescriptor` / `selectedEndpointState` observations and related dead params still present — pending Step 6 | +| `NetworkMockEndpointScreen.kt` | ✅ Rewritten as a proper full screen with endpoint header, VM-driven state, and `previewingResponse` stub | +| `NetworkMockViewModel.kt` | Selection state (`selectedEndpointKey`, `selectedEndpointDescriptor`, `selectedEndpointState`, `selectEndpoint()`, `clearSelectedEndpoint()`) still present — pending Step 7 | +| `NetworkMockEndpointViewModel.kt` | ✅ Created | +| `MockItem.kt` | ✅ `onLongClick`, `isInPreviewMode`, `combinedClickable`, vertical-axis flip animation implemented | +| `NetworkMockEndpointPreviewBottomSheet.kt` | ✅ Complete — Single and Compare modes implemented, smart diff wired | 3c | +| `components/MockResponseDiffContent.kt` | ✅ Created — `DiffLine`, LCS algorithm, `InlineDiffContent`, `SplitDiffContent` | 3c | + +--- + +## Implementation Steps + +### Step 1 — `NetworkMockEndpointViewModel` ✅ Done + +Created `viewmodel/NetworkMockEndpointViewModel.kt` with: +- `uiState: StateFlow` (`Loading` / `Error` / `Content`) +- `setMockState(responseFileName: String?)` delegating to `stateRepository` +- `EndpointLoadingState` private sealed interface (renamed from `LoadingState` to avoid + package-level redeclaration conflict with `NetworkMockViewModel.kt`) + +**Note:** `EndpointDescriptor` was subsequently made `@Serializable` (along with `MockResponse` +and `EndpointConfig`). `NetworkMockDestination.Endpoint` still carries `EndpointKey` rather than +`EndpointDescriptor` — embedding the full descriptor (including all `MockResponse.content` bodies) +in a `NavKey` would bloat serialised navigation state proportionally to mock file count and size. +`EndpointKey` is the correct minimal identifier; the VM reconstructs the descriptor from the +cached repositories on the other side. + +--- + +### Step 2 — `NetworkMockEndpointScreen` ✅ Done + +Rewrote `NetworkMockEndpointScreen.kt` as a proper full screen: +- Removed `ModalBottomSheet`, `rememberModalBottomSheetState`, `rememberCoroutineScope`, + close `IconButton`, `onDismissRequest`, and the wrapping `Box` header +- Final signature: `viewModel: NetworkMockEndpointViewModel`, `modifier`, `bottomPadding` — + repositories are not passed through the screen; the VM is constructed by the caller + (`NetworkMock.registerContent`) and injected directly +- Collects `uiState` from `NetworkMockEndpointViewModel`; delegates to existing `LoadingState` + and `ErrorState` components for those variants +- Extracted the list body into a private `NetworkMockEndpointScreenContent` composable; + wraps the `LazyColumn` in a `Column` with a sticky header `Surface` showing: + - `config.name` at `titleLarge` (was the old bottom sheet title) + - `method` + `path` in monospaced style, consistent with `EndpointCard` + - `EndpointStateChip` for the current active state + - `HorizontalDivider` separating the header from the list +- `bottomPadding` applied via `contentPadding` on the `LazyColumn` +- `previewingResponse: MockResponse?` local state added as a stub — wired to `onPreviewClick` + on each `MockItem` but not yet consumed (intentional — Step 3 will add the sheet) +- `@Preview` calls `NetworkMockEndpointScreenContent` directly with fake data + +--- + +### Step 3 — `MockResponsePreviewSheet` / `NetworkMockEndpointPreviewBottomSheet` ✅ Done + +> **Interaction model:** long-press on a `MockItem` opens/toggles the preview sheet. +> Tap continues to mean "select this mock for the endpoint" — the two actions are fully +> orthogonal. A hint card in the endpoint header tells the user that long-press +> is available, making the gesture discoverable without cluttering the rows. + +#### 3a — `PreviewSheetState` ✅ Done + +Implemented as a `sealed interface` in `NetworkMockEndpointScreen.kt`: + +```kotlin +sealed interface PreviewSheetState { + sealed interface HasResponse : PreviewSheetState + data object Hidden : PreviewSheetState + data class Single(val response: MockResponse) : HasResponse + data class Compare(val left: MockResponse, val right: MockResponse) : HasResponse + + fun transition(response: MockResponse): PreviewSheetState + fun isInPreviewMode(response: MockResponse): Boolean +} +``` + +Transition behaviour (deviates slightly from original plan — better UX): + +| Current state | Long-pressed item | Next state | +|---|---|---| +| `Hidden` | any `r` | `Single(r)` | +| `Single(r)` | same `r` | `Hidden` (toggle off / close) | +| `Single(a)` | different `b` | `Compare(a, b)` | +| `Compare(a, b)` | `a` | `Single(b)` (de-selects `a`, keeps `b`) | +| `Compare(a, b)` | `b` | `Single(a)` (de-selects `b`, keeps `a`) | +| `Compare(a, b)` | new `c` | `Compare(a, b)` unchanged (cap enforced — no-op) | + +#### 3b — `NetworkMockEndpointScreenContent` wiring ✅ Done + +- `previewSheetState: PreviewSheetState` owned in `NetworkMockEndpointScreenContent` +- `showPreviewBottomSheet: Boolean` separate boolean guards the actual sheet composition +- Centered FAB appears with `slideInVertically + fadeIn + scaleIn` when `previewSheetState != Hidden`; tapping it sets `showPreviewBottomSheet = true` +- Each `MockItem` receives `onLongClick = { previewSheetState = previewSheetState.transition(response) }` and `isInPreviewMode = previewSheetState.isInPreviewMode(response)` +- Hint card (`ElevatedCard` + `bodySmall` text) shown above the `HorizontalDivider` +- Sheet composed conditionally: `if (showPreviewBottomSheet && previewSheetState is HasResponse)` + +#### 3c — `NetworkMockEndpointPreviewBottomSheet` body ✅ Done + +`NetworkMockEndpointPreviewBottomSheet.kt` exists with the shell in place: +- `ModalBottomSheet(skipPartiallyExpanded = true)` ✅ +- Animated close via `sheetState.hide()` + `invokeOnCompletion` ✅ +- `HasResponse` typed parameter ✅ +- **Body content not yet implemented** — needs the two modes below. + +##### Single mode (`PreviewSheetState.Single`) + +- Header row: status-code coloured icon chip + `displayName` as title + existing close button +- Body: `Text` with `FontFamily.Monospace` inside `verticalScroll` + `horizontalScroll` + so wide JSON lines do not wrap + +##### Compare mode (`PreviewSheetState.Compare`) + +- Header: two response chips side-by-side + close button at trailing end +- Body: **smart diff** — choose display mode based on content similarity: + +**Diff algorithm (pure Kotlin, no library):** + +Create `components/MockResponseDiffContent.kt` containing: + +```kotlin +internal sealed interface DiffLine { + data class Unchanged(val text: String) : DiffLine + data class Different(val textLeft: String?, val textRight: String?) : DiffLine +} +``` + +1. Split both `content` strings by `\n` into `linesA` and `linesB` +2. Compute LCS (Longest Common Subsequence) of the two line lists — standard O(n²) DP +3. Derive similarity ratio: `lcsLength / max(linesA.size, linesB.size)` +4. If `ratio >= 0.4` → **inline diff** (Mode A); otherwise → **split view** (Mode B) + - Threshold is hardcoded at `0.4` for now + +**Mode A — Inline diff** (similarity ≥ 0.4): +- Single scrollable column, full width +- Each `DiffLine.Unchanged` → plain `Text` in `FontFamily.Monospace` +- Each `DiffLine.Different` → two sub-rows: + - Left line: `primary` colour background tint + `onPrimary` text + - Right line: `secondary` colour background tint + `onSecondary` text +- Labels above the header chips ("left" / "right") clarify which colour belongs to which response +- Avoids red/green semantics — `primary`/`secondary` are neutral "A vs B" colours + +**Mode B — Split view** (similarity < 0.4): +- Two `Column`s stacked **vertically** via `weight(1f)` each in a parent `Column` +- A `HorizontalDivider` separates the two halves +- Each half has its own independent `verticalScroll` + `horizontalScroll` +- Each half has a small chip header identifying which response it shows +- Content rendered in `FontFamily.Monospace` +- No side-by-side splitting — safe for portrait phone screens + +Both `computeLineDiff()` and `shouldUseInlineDiff()` computed via `remember(left.content, right.content)` in the composable so they only rerun when content changes. + +`@Preview` variants needed: +- `Single` with a fake response +- `Compare` — inline diff case (similar content) +- `Compare` — split view case (dissimilar content) + +--- + +### Step 4 — `MockItem` — long-press + animated leading slot swap 🚧 In Progress + +#### 4a — `onLongClick` + `isInPreviewMode` ✅ Done + +`MockItem` updated with: +- `onLongClick: () -> Unit` parameter +- `isInPreviewMode: Boolean` parameter +- `combinedClickable(onClick = onClick, onLongClick = onLongClick)` on the row + +#### 4b — Animated leading slot swap ✅ Done + +Gmail-style vertical-axis flip on the leading icon chip: +- `updateTransition(isInPreviewMode)` animates `flipProgress: Float` from `-1f` → `1f` +- `abs(flipProgress)` applied as `scaleX` via `graphicsLayer` — collapses to 0 at midpoint +- Icon swaps at midpoint via `derivedStateOf { if (flipProgress >= 0f) CheckBox else statusIcon }` +- `NetworkItem` is unaffected (no long-press, no flip) + +#### 4c — Hint card ✅ Done + +`ElevatedCard` with `bodySmall` text above the list divider in `NetworkMockEndpointScreenContent`: +> *"Long press a mock response to be able to preview its content"* + +--- + +### Step 5 — Wire `NetworkMock.registerContent` ✅ Done + +`entry` is fully wired in `NetworkMock.kt`: +- VM constructed inline via `viewModel { NetworkMockEndpointViewModel(...) }` using + `it.endpointKey` from the typed destination +- `modifier = Modifier.fillMaxSize()` and `bottomPadding` forwarded correctly + +--- + +### Step 6 — Clean up `NetworkMockScreen` ✅ TODO + +The bottom-sheet call block is already commented out. Finish the cleanup: + +- Delete the commented-out `selectedDescriptor?.let { ... }` block +- Remove `val selectedDescriptor` and `val selectedEndpointState` observations from + `NetworkMockScreen` +- Remove `setEndpointMockState`, `clearSelectedEndpoint`, `selectedDescriptor`, + `selectedEndpointState` parameters from `NetworkMockScreen`, `NetworkMockScreenContent`, + `ContentState`, and the `@Preview` +- Remove now-unused imports: `EndpointDescriptor`, `EndpointMockState` + +--- + +### Step 7 — Clean up `NetworkMockViewModel` ✅ TODO + +Remove the endpoint selection concern entirely: + +- Delete `selectedEndpointKey: MutableStateFlow` +- Delete `selectedEndpointDescriptor: StateFlow` and its KDoc +- Delete `selectedEndpointState: StateFlow` and its KDoc +- Delete `selectEndpoint(key: EndpointKey)` and its KDoc +- Delete `clearSelectedEndpoint()` and its KDoc +- Remove `EndpointDescriptor` import if it becomes unused after the deletions + +--- + +## Files Changed Summary + +| File | Action | Step | +|---|---|---| +| `viewmodel/NetworkMockEndpointViewModel.kt` | ✅ Created | 1 | +| `NetworkMockEndpointScreen.kt` | ✅ Rewritten — real screen, VM-driven, header added, preview stub in place | 2 | +| `components/MockResponsePreviewSheet.kt` *(new)* | Create preview sheet — single & compare modes; `PreviewSheetState` sealed interface | 3 | +| `components/MockResponseDiffContent.kt` *(new)* | `DiffLine` sealed interface, LCS algorithm, `computeLineDiff()`, `shouldUseInlineDiff()`, `InlineDiffContent`, `SplitDiffContent` composables | 3c | +| `components/MockItem.kt` | Add `MockItemPreviewState` enum + `onLongPress` + `inPreviewMode` boolean + Gmail-style animated leading slot swap (status chip ↔ Checkbox) | 4a | +| `NetworkMockEndpointScreen.kt` | Add hint card to endpoint header; compute `previewState` per item from `previewSheetState` | 4b | +| `NetworkMock.kt` | ✅ `entry` wired with inline VM factory | 5 | +| `NetworkMockScreen.kt` | Remove dead selected-endpoint wiring | 6 | +| `NetworkMockViewModel.kt` | Remove selection state, flows, and methods | 7 | diff --git a/devview-analytics/build.gradle.kts b/devview-analytics/build.gradle.kts index b220127..9b02a00 100644 --- a/devview-analytics/build.gradle.kts +++ b/devview-analytics/build.gradle.kts @@ -10,7 +10,7 @@ plugins { kotlin { addDefaultDevViewTargets() - androidLibrary { + android { namespace = "com.worldline.devview.analytics" } diff --git a/devview-analytics/src/commonMain/kotlin/com/worldline/devview/analytics/Analytics.kt b/devview-analytics/src/commonMain/kotlin/com/worldline/devview/analytics/Analytics.kt index f964f6b..abbe375 100644 --- a/devview-analytics/src/commonMain/kotlin/com/worldline/devview/analytics/Analytics.kt +++ b/devview-analytics/src/commonMain/kotlin/com/worldline/devview/analytics/Analytics.kt @@ -15,6 +15,7 @@ import com.worldline.devview.core.Module import com.worldline.devview.core.ModuleDestinationActionPopup import com.worldline.devview.core.Section import com.worldline.devview.core.withTitle +import kotlin.reflect.KClass import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toPersistentList @@ -121,7 +122,7 @@ public class Analytics( * events from the in-memory store, with a confirmation popup to prevent * accidental data loss. */ - override val destinations: PersistentMap = persistentMapOf( + override val destinations: PersistentMap, DestinationMetadata> = persistentMapOf( AnalyticsDestination.Main.withTitle(title = "Analytics") { action( icon = Icons.Rounded.Delete, @@ -137,6 +138,8 @@ public class Analytics( } ) + override val entryDestination: NavKey = AnalyticsDestination.Main + /** * Registers serializers for navigation destinations. * diff --git a/devview-featureflip/build.gradle.kts b/devview-featureflip/build.gradle.kts index 6d8f9c9..dbcf8db 100644 --- a/devview-featureflip/build.gradle.kts +++ b/devview-featureflip/build.gradle.kts @@ -9,7 +9,7 @@ plugins { kotlin { addDefaultDevViewTargets() - androidLibrary { + android { namespace = "com.worldline.devview.featureflip" } diff --git a/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/FeatureFlip.kt b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/FeatureFlip.kt index b8a7058..2bbb96d 100644 --- a/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/FeatureFlip.kt +++ b/devview-featureflip/src/commonMain/kotlin/com/worldline/devview/featureflip/FeatureFlip.kt @@ -11,6 +11,7 @@ import com.worldline.devview.core.Section import com.worldline.devview.core.withTitle import com.worldline.devview.utils.DataStoreDelegate import com.worldline.devview.utils.RequiresDataStore +import kotlin.reflect.KClass import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.persistentMapOf import kotlinx.serialization.Serializable @@ -148,10 +149,12 @@ public object FeatureFlip : Module, RequiresDataStore { * * Currently includes only the main feature management screen. */ - override val destinations: PersistentMap = persistentMapOf( + override val destinations: PersistentMap, DestinationMetadata> = persistentMapOf( FeatureFlipDestination.Main.withTitle(title = "Feature Flip") ) + override val entryDestination: NavKey = FeatureFlipDestination.Main + /** * Registers serializers for navigation destinations. * diff --git a/devview-networkmock-core/build.gradle.kts b/devview-networkmock-core/build.gradle.kts index 9834ed3..7a441a4 100644 --- a/devview-networkmock-core/build.gradle.kts +++ b/devview-networkmock-core/build.gradle.kts @@ -9,7 +9,7 @@ plugins { kotlin { addDefaultDevViewTargets() - androidLibrary { + android { namespace = "com.worldline.devview.networkmock.core" } diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/MockConfiguration.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/MockConfiguration.kt index e53651b..6373389 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/MockConfiguration.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/MockConfiguration.kt @@ -8,8 +8,9 @@ import kotlinx.serialization.Serializable * * This data class represents the complete mock configuration that integrators * define in their `composeResources/files/networkmocks/mocks.json` file. It - * contains all host configurations and their associated API endpoints that - * can be mocked during development and testing. + * contains all API group configurations, each of which defines a set of shared + * endpoints and the environments (deployment stages) in which those endpoints + * are reachable. * * ## File Location * The configuration file should be placed at: @@ -20,17 +21,29 @@ import kotlinx.serialization.Serializable * ## JSON Structure * ```json * { - * "hosts": [ + * "apiGroups": [ * { - * "id": "staging", - * "url": "https://staging.api.example.com", + * "id": "my-backend", + * "name": "My Backend", * "endpoints": [ * { * "id": "getUser", - * "name": "Get User Profile", - * "path": "/api/v1/user/{userId}", + * "name": "Get User", + * "path": "/v1/users/{userId}", * "method": "GET" * } + * ], + * "environments": [ + * { + * "id": "staging", + * "name": "Staging", + * "url": "https://staging.api.example.com" + * }, + * { + * "id": "production", + * "name": "Production", + * "url": "https://api.example.com" + * } * ] * } * ] @@ -42,67 +55,187 @@ import kotlinx.serialization.Serializable * val repository = MockConfigRepository("files/networkmocks/mocks.json") * val config = repository.loadConfiguration().getOrNull() * - * config?.hosts?.forEach { host -> - * println("Host: ${host.id} - ${host.url}") - * host.endpoints.forEach { endpoint -> - * println(" - ${endpoint.method} ${endpoint.path}") + * config?.apiGroups?.forEach { group -> + * println("Group: ${group.id}") + * group.environments.forEach { env -> + * println(" Environment: ${env.id} - ${env.url}") + * } + * group.endpoints.forEach { endpoint -> + * println(" Endpoint: ${endpoint.method} ${endpoint.path}") * } * } * ``` * - * @property hosts List of host configurations, each containing API endpoints - * @see HostConfig + * @property apiGroups List of API group configurations, each representing a named + * logical backend with its shared endpoints and environment-specific base URLs + * @see ApiGroupConfig + * @see EnvironmentConfig * @see EndpointConfig * @see com.worldline.devview.networkmock.repository.MockConfigRepository */ @Serializable -public data class MockConfiguration(val hosts: List) +public data class MockConfiguration(val apiGroups: List) /** - * Configuration for a specific API host and its endpoints. + * Configuration for a named, stable API group (e.g. "my-backend", "jsonplaceholder"). * - * A host represents a backend server (e.g., staging, production, development) - * that the application communicates with. Each host can have multiple API - * endpoints that can be individually mocked. + * An API group is the top-level organisational unit in the mock configuration. It + * represents a logical backend that the application communicates with, regardless + * of which environment is active. It defines: + * - A shared pool of [endpoints] that exist across all environments + * - A list of [environments], each providing the base URL for this group at a + * given deployment stage, along with optional per-environment endpoint overrides + * and additions * - * ## Usage in Configuration - * Multiple hosts can be defined to support different environments: + * ## Shared Endpoints vs. Environment-Specific Variations + * Endpoints are defined once in [endpoints] and shared across all environments. + * When an endpoint differs in a specific environment (different path, name or method), + * an [EndpointOverride] can be declared inside that [EnvironmentConfig]. Endpoints + * that only exist in a specific environment are declared in + * [EnvironmentConfig.additionalEndpoints]. + * + * ## Example * ```json * { - * "hosts": [ - * { - * "id": "staging", - * "url": "https://staging.api.example.com", - * "endpoints": [...] - * }, - * { - * "id": "production", - * "url": "https://api.example.com", - * "endpoints": [...] - * } + * "id": "my-backend", + * "name": "My Backend", + * "endpoints": [ + * { "id": "getUser", "name": "Get User", "path": "/v1/users/{userId}", "method": "GET" } + * ], + * "environments": [ + * { "id": "staging", "name": "Staging", "url": "https://staging.api.example.com" }, + * { "id": "production", "name": "Production", "url": "https://api.example.com" } * ] * } * ``` * - * ## Host Matching - * When a network request is made, the plugin extracts the hostname from the - * request URL and compares it against configured hosts. Only endpoints from - * the matching host will be considered for mocking. + * @property id Stable unique identifier for this group (e.g. `"my-backend"`, `"jsonplaceholder"`). + * Used as the first path segment of mock response files: + * `responses/{id}/{endpointId}/` or `responses/{id}/{environmentId}/{endpointId}/` + * @property name Human-readable name displayed in the UI (e.g. `"My Backend"`) + * @property endpoints Shared endpoint definitions, available in all environments unless + * overridden or omitted via [EnvironmentConfig.endpointOverrides] / + * [EnvironmentConfig.additionalEndpoints] + * @property environments List of deployment stages for this group, each providing + * the resolved base URL and optional endpoint customisations + * @see EnvironmentConfig + * @see EndpointConfig + * @see EndpointOverride + */ +@Serializable +public data class ApiGroupConfig( + val id: String, + val name: String, + val endpoints: List, + val environments: List +) + +/** + * Configuration for a single deployment environment within an [ApiGroupConfig]. * - * @property id Unique identifier for this host (used in UI and state persistence) - * @property url The base URL of the API host (scheme + hostname, e.g., "https://api.example.com") - * @property endpoints List of API endpoints available on this host + * An environment represents one stage in the delivery pipeline (e.g. staging, + * production, development). It provides the resolved base URL for its parent + * [ApiGroupConfig] at that stage, and optionally customises the shared endpoint + * pool via [endpointOverrides] and [additionalEndpoints]. + * + * ## Endpoint Resolution + * When the active environment is matched during request interception, the effective + * endpoint list for this environment is built as follows: + * 1. Start with [ApiGroupConfig.endpoints] (the shared pool) + * 2. Apply [endpointOverrides] — each override merges into the matching shared + * endpoint by [EndpointOverride.id]; unspecified fields keep their shared value + * 3. Append [additionalEndpoints] — endpoints that only exist in this environment + * + * ## Example + * ```json + * { + * "id": "production", + * "name": "Production", + * "url": "https://api.example.com", + * "endpointOverrides": [ + * { "id": "getUser", "path": "/v1/users/{userId}/profile" } + * ], + * "additionalEndpoints": [ + * { "id": "getLegacyUser", "name": "Get Legacy User", "path": "/users/{userId}", "method": "GET" } + * ] + * } + * ``` + * + * @property id Unique identifier for this environment (e.g. `"staging"`, `"production"`). + * Used as the second path segment of environment-specific mock response files: + * `responses/{groupId}/{id}/{endpointId}/` + * and as part of the DataStore endpoint state key: `"{id}-{endpointId}"` + * @property name Human-readable name displayed in the UI (e.g. `"Staging"`, `"Production"`) + * @property url The base URL for the parent API group in this environment + * (scheme + hostname, e.g. `"https://staging.api.example.com"`). + * The hostname is extracted at runtime and compared against incoming request hosts. + * @property endpointOverrides Optional list of partial endpoint replacements. Each entry + * references a shared endpoint by [EndpointOverride.id] and overrides only the + * fields it specifies. Defaults to an empty list. + * @property additionalEndpoints Optional list of endpoints that only exist in this + * environment (e.g. staging-only debug endpoints). Defaults to an empty list. + * @see ApiGroupConfig + * @see EndpointOverride * @see EndpointConfig */ @Serializable -public data class HostConfig(val id: String, val url: String, val endpoints: List) +public data class EnvironmentConfig( + val id: String, + val name: String, + val url: String, + val endpointOverrides: List = emptyList(), + val additionalEndpoints: List = emptyList() +) + +/** + * Common contract for endpoint definitions in the mock configuration. + * + * This sealed interface is implemented by both [EndpointConfig] (a complete, + * standalone endpoint definition) and [EndpointOverride] (a partial, environment-specific + * replacement). Both share the same set of fields; the distinction is that all fields + * are non-null in [EndpointConfig] whereas [EndpointOverride] makes them nullable to + * express "only override the fields that differ". + * + * Having a shared interface allows the effective-endpoint resolution logic to operate + * uniformly over both types without casting: + * ```kotlin + * val effective: EndpointConfig = sharedEndpoint.applyOverride(override) + * ``` + * + * @see EndpointConfig + * @see EndpointOverride + * @see ApiGroupConfig.effectiveEndpoints + */ +public sealed interface EndpointDefinition { + /** Endpoint identifier — non-null in [EndpointConfig], non-null in [EndpointOverride] (used as lookup key). */ + public val id: String + + /** Human-readable display name — non-null in [EndpointConfig], nullable in [EndpointOverride]. */ + public val name: String? + + /** API path, may contain `{param}` placeholders — non-null in [EndpointConfig], nullable in [EndpointOverride]. */ + public val path: String? + + /** HTTP method (GET, POST, …) — non-null in [EndpointConfig], nullable in [EndpointOverride]. */ + public val method: String? +} /** * Configuration for a single API endpoint that can be mocked. * * An endpoint represents a specific API call (combination of HTTP method and path) - * that can have multiple mock responses. The actual mock response files should - * be placed in `composeResources/files/networkmocks/responses/{endpointId}/` + * that can have multiple mock responses. Endpoints are defined in the shared pool + * of an [ApiGroupConfig] and apply to all environments unless overridden via + * [EndpointOverride] or supplemented via [EnvironmentConfig.additionalEndpoints]. + * + * All fields are non-null — this is a complete, self-contained endpoint definition. + * Use [EndpointOverride] to express a partial replacement for a specific environment. + * + * Mock response files for an endpoint should be placed at: + * ``` + * responses/{groupId}/{environmentId}/{endpointId}/ ← environment-specific (highest priority) + * responses/{groupId}/{endpointId}/ ← shared fallback (lowest priority) + * ``` * following the naming convention: `{endpointId}-{statusCode}[-{suffix}].json` * * ## Path Parameters @@ -112,13 +245,13 @@ public data class HostConfig(val id: String, val url: String, val endpoints: Lis * - Path: `/api/posts/{postId}/comments/{commentId}` matches any values for both IDs * * ## Response File Convention - * For an endpoint with `id = "getUser"`, response files should be named: + * For an endpoint with `id = "getUser"` in group `"my-backend"`: * ``` - * responses/getUser/ - * ├── getUser-200.json (Success response) - * ├── getUser-404-simple.json (Not found - simple error) - * ├── getUser-404-detailed.json (Not found - detailed error) - * └── getUser-500.json (Server error) + * responses/my-backend/getUser/ + * ├── getUser-200.json (Shared success response) + * └── getUser-404.json (Shared not found response) + * responses/my-backend/staging/getUser/ + * └── getUser-200.json (Staging-specific success response — overrides shared) * ``` * * ## Usage Example @@ -126,23 +259,291 @@ public data class HostConfig(val id: String, val url: String, val endpoints: Lis * { * "id": "getUser", * "name": "Get User Profile", - * "path": "/api/v1/user/{userId}", + * "path": "/v1/users/{userId}", * "method": "GET" * } * ``` * - * @property id Unique identifier for this endpoint within its host (used for file discovery and state persistence) + * @property id Unique identifier for this endpoint within its [ApiGroupConfig]. + * Used for state persistence, file discovery, and override matching. * @property name Human-readable name displayed in the UI - * @property path API path with optional parameters in curly braces (e.g., "/api/users/{userId}") + * @property path API path with optional `{param}` placeholders (e.g. `"/v1/users/{userId}"`) * @property method HTTP method (GET, POST, PUT, DELETE, PATCH, etc.) - * @see HostConfig + * @see EndpointDefinition + * @see EndpointOverride + * @see ApiGroupConfig + * @see EnvironmentConfig * @see MockResponse */ @Immutable @Serializable public data class EndpointConfig( - val id: String, - val name: String, - val path: String, - val method: String -) + override val id: String, + override val name: String, + override val path: String, + override val method: String +) : EndpointDefinition + +/** + * A partial override for a shared [EndpointConfig] within a specific [EnvironmentConfig]. + * + * Implements [EndpointDefinition] alongside [EndpointConfig], sharing the same field + * names. The key difference is that [name], [path] and [method] are nullable here — + * a `null` value means "keep the shared value from [ApiGroupConfig.endpoints]". + * Only [id] is non-null, as it is the lookup key that identifies which shared endpoint + * this override targets. + * + * ## Merge Behaviour + * Given a shared endpoint: + * ```json + * { "id": "getUser", "name": "Get User", "path": "/v1/users/{userId}", "method": "GET" } + * ``` + * And an override: + * ```json + * { "id": "getUser", "path": "/v1/users/{userId}/profile" } + * ``` + * The effective endpoint for this environment becomes: + * ``` + * id: getUser + * name: Get User ← from shared (null in override → keep shared) + * path: /v1/users/{userId}/profile ← from override + * method: GET ← from shared (null in override → keep shared) + * ``` + * + * ## Merge Logic + * The merge is performed by [ApiGroupConfig.effectiveEndpoints], which calls: + * ```kotlin + * sharedEndpoint.copy( + * name = override.name ?: sharedEndpoint.name, + * path = override.path ?: sharedEndpoint.path, + * method = override.method ?: sharedEndpoint.method + * ) + * ``` + * + * @property id The [EndpointConfig.id] of the shared endpoint to override. + * Must match an entry in [ApiGroupConfig.endpoints]; unrecognised IDs are ignored. + * @property name Replacement display name, or `null` to keep the shared value + * @property path Replacement API path, or `null` to keep the shared value + * @property method Replacement HTTP method, or `null` to keep the shared value + * @see EndpointDefinition + * @see EndpointConfig + * @see EnvironmentConfig + * @see ApiGroupConfig + */ +@Serializable +public data class EndpointOverride( + override val id: String, + override val name: String? = null, + override val path: String? = null, + override val method: String? = null +) : EndpointDefinition + +/** + * A stable, value-type identifier for the triple (groupId, environmentId, endpointId). + * + * This key uniquely addresses a single endpoint within a specific deployment environment + * of a specific API group. It is used everywhere the three identifiers travel together — + * as a map key, as a lookup token, and as the carrier inside [MockMatch] and + * [EndpointDescriptor] — so that callers always have named access to each component + * instead of relying on positional destructuring of a raw [Triple]. + * + * ## Composite string key + * Use [compositeKey] to obtain the `"{groupId}-{environmentId}-{endpointId}"` string + * required by DataStore and [com.worldline.devview.networkmock.model.NetworkMockState]: + * ```kotlin + * val key = EndpointKey("my-backend", "staging", "getUser").compositeKey + * // "my-backend-staging-getUser" + * ``` + * + * @property groupId The [ApiGroupConfig.id] (e.g. `"my-backend"`) + * @property environmentId The [EnvironmentConfig.id] (e.g. `"staging"`) + * @property endpointId The [EndpointConfig.id] (e.g. `"getUser"`) + * @see MockMatch + * @see EndpointDescriptor + * @see com.worldline.devview.networkmock.model.NetworkMockState + * @see com.worldline.devview.networkmock.repository.MockStateRepository + * @see com.worldline.devview.networkmock.repository.MockConfigRepository + */ +@Immutable +@Serializable +public data class EndpointKey( + val groupId: String, + val environmentId: String, + val endpointId: String +) { + /** + * The canonical `"{groupId}-{environmentId}-{endpointId}"` string used as a + * DataStore preference key suffix and as the key in + * [com.worldline.devview.networkmock.model.NetworkMockState.endpointStates]. + */ + public val compositeKey: String get() = "$groupId-$environmentId-$endpointId" + + public companion object +} + +/** + * Represents a matched mock configuration for an incoming HTTP request. + * + * When the network mock plugin intercepts a request, it uses the active environment, + * the request host, path, and method to find a matching endpoint configuration. + * If found, this data class contains the necessary information to locate and load + * the appropriate mock response. + * + * ## Matching Process + * 1. Iterate over all [ApiGroupConfig] entries in the configuration + * 2. For each group, find the [EnvironmentConfig] whose hostname extracted from + * [EnvironmentConfig.url] matches the incoming request host + * 3. Build the effective endpoint list for this group+environment (shared + overrides + additions) + * 4. Find the [EndpointConfig] matching the request path and method + * 5. Return a [MockMatch] with the resolved identifiers + * + * ## Usage in Plugin + * ```kotlin + * val mockMatch = mockRepository.findMatchingMock( + * host = "staging.api.example.com", + * path = "/v1/users/123", + * method = "GET" + * ) + * + * mockMatch?.let { match -> + * val endpointState = currentState.endpointStates[match.key.compositeKey] + * // ... + * } + * ``` + * + * @property key The [EndpointKey] carrying the matched group, environment, and endpoint identifiers + * @property config The complete effective endpoint configuration after override resolution + * @see MockConfiguration + * @see ApiGroupConfig + * @see EnvironmentConfig + * @see EndpointConfig + * @see EndpointKey + * @see com.worldline.devview.networkmock.repository.MockConfigRepository.findMatchingMock + */ +@Immutable +public data class MockMatch(val key: EndpointKey, val config: EndpointConfig) { + /** The [ApiGroupConfig.id] of the matched group. Convenience accessor for [EndpointKey.groupId]. */ + public val groupId: String get() = key.groupId + + /** The [EnvironmentConfig.id] of the matched environment. Convenience accessor for [EndpointKey.environmentId]. */ + public val environmentId: String get() = key.environmentId + + /** The [EndpointConfig.id] of the matched endpoint. Convenience accessor for [EndpointKey.endpointId]. */ + public val endpointId: String get() = key.endpointId +} + +/** + * Represents the static descriptor for an available endpoint and its mock responses. + * + * This data class combines endpoint configuration and discovered response files + * to provide a complete, immutable view of an endpoint's mocking capabilities. + * It is primarily used by the UI layer to display available endpoints and their + * configurations. + * + * Runtime selection state is intentionally excluded from this model. It changes + * on every user interaction and belongs in the UI layer paired with this descriptor, + * keeping this class safe to snapshot, store, and pass freely without going stale. + * + * ## UI Usage + * The UI uses this model to display: + * - Endpoint name and path + * - List of available mock responses + * + * @property key The [EndpointKey] uniquely identifying this endpoint within its group and environment + * @property config The effective endpoint configuration after override resolution + * @property availableResponses List of discovered mock response files for this + * group + environment + endpoint combination + * @see MockResponse + * @see EndpointKey + * @see EndpointConfig + * @see ApiGroupConfig + * @see EnvironmentConfig + */ +@Immutable +@Serializable +public data class EndpointDescriptor( + val key: EndpointKey, + val config: EndpointConfig, + val availableResponses: List +) { + /** + * The [ApiGroupConfig.id] this endpoint belongs to. Convenience accessor for [EndpointKey.groupId]. + */ + public val groupId: String get() = key.groupId + + /** + * The [EnvironmentConfig.id] this descriptor was resolved for. Convenience accessor for + * [EndpointKey.environmentId]. + */ + public val environmentId: String get() = key.environmentId + + /** + * The [EndpointConfig.id] for this endpoint. Convenience accessor for [EndpointKey.endpointId]. + */ + public val endpointId: String get() = key.endpointId + + public companion object +} + +/** + * Builds the effective endpoint list for a given [ApiGroupConfig] and [EnvironmentConfig] pair. + * + * This is the single source of truth for endpoint resolution. It combines the group's + * shared [ApiGroupConfig.endpoints] pool with the environment's [EnvironmentConfig.endpointOverrides] + * and [EnvironmentConfig.additionalEndpoints] to produce the complete, ready-to-use list of + * [EndpointConfig] entries that apply to this specific group + environment combination. + * + * ## Resolution Steps + * 1. Start with [ApiGroupConfig.endpoints] (the shared pool) + * 2. For each shared endpoint, check whether [EnvironmentConfig.endpointOverrides] contains + * an entry whose [EndpointOverride.id] matches — if so, merge it: non-null override fields + * replace the shared values, null override fields keep the shared values + * 3. Append [EnvironmentConfig.additionalEndpoints] — these endpoints are unique to this + * environment and are added as-is after the resolved shared endpoints + * + * ## Override Merge Example + * Shared endpoint: + * ``` + * EndpointConfig(id="getUser", name="Get User", path="/v1/users/{userId}", method="GET") + * ``` + * Override: + * ``` + * EndpointOverride(id="getUser", path="/v1/users/{userId}/profile") + * ``` + * Result: + * ``` + * EndpointConfig(id="getUser", name="Get User", path="/v1/users/{userId}/profile", method="GET") + * ``` + * + * ## Usage + * ```kotlin + * val effectiveEndpoints = group.effectiveEndpoints(environment) + * val match = effectiveEndpoints.firstOrNull { endpoint -> + * RequestMatcher.matchesPath(endpoint.path, requestPath) && endpoint.method == requestMethod + * } + * ``` + * + * @receiver The [ApiGroupConfig] providing the shared endpoint pool + * @param environment The [EnvironmentConfig] providing overrides and additions + * @return The fully resolved list of [EndpointConfig] for this group + environment, + * in order: resolved shared endpoints first, then additional endpoints + * @see EndpointOverride + * @see EnvironmentConfig.endpointOverrides + * @see EnvironmentConfig.additionalEndpoints + */ +public fun ApiGroupConfig.effectiveEndpoints(environment: EnvironmentConfig): List { + val overrideMap = environment.endpointOverrides.associateBy { it.id } + val resolved = endpoints.map { shared -> + val override = overrideMap[shared.id] + if (override == null) { + shared + } else { + shared.copy( + name = override.name ?: shared.name, + path = override.path ?: shared.path, + method = override.method ?: shared.method + ) + } + } + return resolved + environment.additionalEndpoints +} diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/MockResponse.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/MockResponse.kt index b951989..716a9f0 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/MockResponse.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/MockResponse.kt @@ -2,6 +2,7 @@ package com.worldline.devview.networkmock.model import androidx.compose.runtime.Immutable import com.worldline.devview.networkmock.utils.parseStatusCode +import kotlinx.serialization.Serializable /** * Represents a loaded mock response that can be returned by the network mock plugin. @@ -74,6 +75,7 @@ import com.worldline.devview.networkmock.utils.parseStatusCode * @see com.worldline.devview.networkmock.repository.MockConfigRepository */ @Immutable +@Serializable public data class MockResponse( val statusCode: Int, val fileName: String, @@ -214,82 +216,4 @@ public data class MockResponse( } } -/** - * Represents a matched mock configuration for an incoming HTTP request. - * - * When the network mock plugin intercepts a request, it uses the request's - * host, path, and method to find a matching endpoint configuration. If found, - * this data class contains the necessary information to locate and load the - * appropriate mock response. - * - * ## Matching Process - * 1. Extract hostname from request URL - * 2. Find matching [HostConfig] by comparing hostnames - * 3. Find matching [EndpointConfig] by comparing path and method - * 4. Return [MockMatch] containing host ID, endpoint ID, and config - * - * ## Usage in Plugin - * ```kotlin - * val mockMatch = mockRepository.findMatchingMock( - * host = "staging.api.example.com", - * path = "/api/v1/user/123", - * method = "GET" - * ) - * - * mockMatch?.let { match -> - * val endpointKey = "${match.hostId}-${match.endpointId}" - * val endpointState = currentState.endpointStates[endpointKey] - * - * if (endpointState?.shouldUseMock() == true) { - * val response = mockRepository.loadMockResponse( - * endpointId = match.endpointId, - * fileName = endpointState.selectedResponseFile!! - * ) - * // Return mock response - * } - * } - * ``` - * - * @property hostId The identifier of the matched host (e.g., "staging") - * @property endpointId The identifier of the matched endpoint (e.g., "getUser") - * @property config The complete endpoint configuration - * @see MockConfiguration - * @see EndpointConfig - * @see com.worldline.devview.networkmock.repository.MockConfigRepository.findMatchingMock - */ -@Immutable -public data class MockMatch(val hostId: String, val endpointId: String, val config: EndpointConfig) - -/** - * Represents the static descriptor for an available endpoint and its mock responses. - * - * This data class combines endpoint configuration and discovered response files - * to provide a complete, immutable view of an endpoint's mocking capabilities. - * It is primarily used by the UI layer to display available endpoints and their - * configurations. - * - * Runtime selection state is intentionally excluded from this model. It changes - * on every user interaction and belongs in the UI layer paired with this descriptor, - * keeping this class safe to snapshot, store, and pass freely without going stale. - * - * ## UI Usage - * The UI uses this model to display: - * - Endpoint name and path - * - List of available mock responses - * - * @property hostId The host identifier this endpoint belongs to - * @property endpointId The endpoint identifier - * @property config The endpoint configuration from mocks.json - * @property availableResponses List of discovered mock response files - * @see MockResponse - * @see EndpointConfig - */ -@Immutable -public data class EndpointDescriptor( - val hostId: String, - val endpointId: String, - val config: EndpointConfig, - val availableResponses: List -) { - public companion object -} +// MockMatch and EndpointDescriptor have been moved to MockConfiguration.kt diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/NetworkMockState.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/NetworkMockState.kt index ec1fac7..7ad4d20 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/NetworkMockState.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/model/NetworkMockState.kt @@ -31,8 +31,7 @@ import kotlinx.serialization.json.JsonClassDiscriminator * * // Configure specific endpoint * repository.setEndpointMockState( - * hostId = "staging", - * endpointId = "getUser", + * key = EndpointKey("my-backend", "staging", "getUser"), * state = EndpointMockState.Mock(responseFile = "getUser-200.json") * ) * ``` @@ -44,16 +43,23 @@ import kotlinx.serialization.json.JsonClassDiscriminator * * This allows quick testing with/without mocking while preserving individual configurations. * + * ## Environment Resolution + * There is no stored "active environment" selection. The environment is derived at runtime + * by matching the incoming request's hostname against the [com.worldline.devview.networkmock.model.EnvironmentConfig.url] + * of each environment across all API groups. This allows the app to simultaneously target + * different environments for different API groups without any manual selection. + * * ## Endpoint State Keys - * Endpoint states are keyed by `"{hostId}-{endpointId}"` to ensure uniqueness - * across multiple hosts that might have endpoints with the same ID: + * Endpoint states are keyed by `"{groupId}-{environmentId}-{endpointId}"` to guarantee + * uniqueness across all combinations. This prevents collisions between API groups that + * happen to share the same environment ID and endpoint ID: * ```kotlin - * val key = "staging-getUser" // For staging host's getUser endpoint + * val key = "my-backend-staging-getUser" // my-backend group, staging environment, getUser endpoint * val state = networkMockState.endpointStates[key] * ``` * - * @property globalMockingEnabled Master toggle - when false, all mocking is disabled - * @property endpointStates Map of endpoint states, keyed by "{hostId}-{endpointId}" + * @property globalMockingEnabled Master toggle — when `false`, all mocking is disabled + * @property endpointStates Map of endpoint states, keyed by `"{groupId}-{environmentId}-{endpointId}"` * @property lastModified Timestamp (milliseconds since epoch) of last state modification * @see EndpointMockState * @see com.worldline.devview.networkmock.repository.MockStateRepository @@ -65,36 +71,71 @@ public data class NetworkMockState( val lastModified: Long = 0L ) { /** - * Gets the state for a specific endpoint. + * Gets the mock state for a specific endpoint identified by an [EndpointKey]. * - * @param hostId The host identifier - * @param endpointId The endpoint identifier - * @return The [EndpointMockState] if configured, or null if not set + * @param key The [EndpointKey] identifying the group, environment, and endpoint + * @return The [EndpointMockState] if configured, or `null` if not set */ - public fun getEndpointState(hostId: String, endpointId: String): EndpointMockState? { - val key = "$hostId-$endpointId" - return endpointStates[key] - } + public fun getEndpointState(key: EndpointKey): EndpointMockState? = + endpointStates[key.compositeKey] + + /** + * Gets the mock state for a specific endpoint in a specific group and environment. + * + * Convenience overload of [getEndpointState] that accepts three separate string + * identifiers instead of an [EndpointKey]. Delegates to the [EndpointKey] overload. + * + * @param groupId The [com.worldline.devview.networkmock.model.ApiGroupConfig] identifier + * @param environmentId The [com.worldline.devview.networkmock.model.EnvironmentConfig] identifier + * @param endpointId The [com.worldline.devview.networkmock.model.EndpointConfig] identifier + * @return The [EndpointMockState] if configured, or `null` if not set + */ + public fun getEndpointState( + groupId: String, + environmentId: String, + endpointId: String + ): EndpointMockState? = getEndpointState( + key = EndpointKey(groupId = groupId, environmentId = environmentId, endpointId = endpointId) + ) + + /** + * Creates a new state with the specified endpoint state updated. + * + * @param key The [EndpointKey] identifying the group, environment, and endpoint + * @param state The new endpoint state + * @return A new [NetworkMockState] with the updated endpoint state + */ + public fun withEndpointState(key: EndpointKey, state: EndpointMockState): NetworkMockState = + copy( + endpointStates = endpointStates + (key.compositeKey to state), + lastModified = Clock.System.now().toEpochMilliseconds() + ) /** * Creates a new state with the specified endpoint state updated. * - * @param hostId The host identifier - * @param endpointId The endpoint identifier + * Convenience overload of [withEndpointState] that accepts three separate string + * identifiers instead of an [EndpointKey]. Delegates to the [EndpointKey] overload. + * + * @param groupId The [com.worldline.devview.networkmock.model.ApiGroupConfig] identifier + * @param environmentId The [com.worldline.devview.networkmock.model.EnvironmentConfig] identifier + * @param endpointId The [com.worldline.devview.networkmock.model.EndpointConfig] identifier * @param state The new endpoint state * @return A new [NetworkMockState] with the updated endpoint state */ public fun withEndpointState( - hostId: String, + groupId: String, + environmentId: String, endpointId: String, state: EndpointMockState - ): NetworkMockState { - val key = "$hostId-$endpointId" - return copy( - endpointStates = endpointStates + (key to state), - lastModified = Clock.System.now().toEpochMilliseconds() - ) - } + ): NetworkMockState = withEndpointState( + key = EndpointKey( + groupId = groupId, + environmentId = environmentId, + endpointId = endpointId + ), + state = state + ) /** * Creates a new state with all endpoint mocks reset to use the actual network. diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/repository/MockConfigRepository.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/repository/MockConfigRepository.kt index a1f88ad..441c602 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/repository/MockConfigRepository.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/repository/MockConfigRepository.kt @@ -1,8 +1,11 @@ package com.worldline.devview.networkmock.repository +import com.worldline.devview.networkmock.model.ApiGroupConfig +import com.worldline.devview.networkmock.model.EndpointKey import com.worldline.devview.networkmock.model.MockConfiguration import com.worldline.devview.networkmock.model.MockMatch import com.worldline.devview.networkmock.model.MockResponse +import com.worldline.devview.networkmock.model.effectiveEndpoints import kotlinx.serialization.json.Json /** @@ -22,15 +25,14 @@ import kotlinx.serialization.json.Json * All mock-related files should be placed in the integrator's project at: * ``` * composeResources/files/networkmocks/ - * ├── mocks.json # Main configuration - * └── responses/ # Response files organized by endpoint - * ├── getUser/ - * │ ├── getUser-200.json - * │ ├── getUser-404-simple.json - * │ └── getUser-500.json - * └── createUser/ - * ├── createUser-201.json - * └── createUser-400.json + * ├── mocks.json # Main configuration + * └── responses/ + * └── {groupId}/ + * ├── {environmentId}/ # Environment-specific responses (highest priority) + * │ └── {endpointId}/ + * │ └── {endpointId}-200.json + * └── {endpointId}/ # Shared fallback responses (lowest priority) + * └── {endpointId}-200.json * ``` * * ## Usage @@ -48,9 +50,12 @@ import kotlinx.serialization.json.Json * ```kotlin * val configResult = repository.loadConfiguration() * configResult.onSuccess { config -> - * config.hosts.forEach { host -> - * println("Host: ${host.id} - ${host.url}") - * host.endpoints.forEach { endpoint -> + * config.apiGroups.forEach { group -> + * println("Group: ${group.id}") + * group.environments.forEach { env -> + * println(" Environment: ${env.id} - ${env.url}") + * } + * group.endpoints.forEach { endpoint -> * println(" ${endpoint.method} ${endpoint.path}") * } * } @@ -59,7 +64,8 @@ import kotlinx.serialization.json.Json * * ### Discovering Response Files * ```kotlin - * val responses = repository.discoverResponseFiles("getUser") + * val key = EndpointKey(groupId = "my-backend", environmentId = "staging", endpointId = "getUser") + * val responses = repository.discoverResponseFiles(key = key) * responses.forEach { response -> * println("${response.displayName}: ${response.fileName}") * } @@ -69,21 +75,19 @@ import kotlinx.serialization.json.Json * ```kotlin * val match = repository.findMatchingMock( * host = "staging.api.example.com", - * path = "/api/v1/user/123", + * path = "/v1/users/123", * method = "GET" * ) * * match?.let { - * println("Matched endpoint: ${it.endpointId} on host ${it.hostId}") + * println("Matched: ${it.key.compositeKey}") * } * ``` * * ### Loading Response Content * ```kotlin - * val response = repository.loadMockResponse( - * endpointId = "getUser", - * fileName = "getUser-200.json" - * ) + * val key = EndpointKey(groupId = "my-backend", environmentId = "staging", endpointId = "getUser") + * val response = repository.loadMockResponse(key = key, fileName = "getUser-200.json") * * response?.let { * println("Status: ${it.statusCode}") @@ -94,14 +98,14 @@ import kotlinx.serialization.json.Json * ## Error Handling * - [loadConfiguration] returns a [Result] that can be success or failure * - [findMatchingMock] returns `null` if no match is found - * - [loadMockResponse] returns `null` if the file cannot be loaded + * - [loadMockResponse] returns `null` if the file cannot be loaded in either location * - [discoverResponseFiles] returns empty list if no files are found * * @property configPath The path to the mocks.json file relative to composeResources * @property resourceLoader Function to load resource bytes from a path * @property statusCodesToDiscover The list of HTTP status codes to probe when - * discovering response files. Defaults to [DEFAULT_STATUS_CODES]. Override this - * to include non-standard status codes used by your API. + * discovering response files. Defaults to [DEFAULT_STATUS_CODES]. Override this + * to include non-standard status codes used by your API. * @see MockConfiguration * @see MockResponse * @see MockMatch @@ -139,8 +143,8 @@ public class MockConfigRepository( * Loads and parses the mock configuration from the JSON file. * * This method reads the configuration file from Compose Resources, parses - * the JSON, and returns a [MockConfiguration] object containing all hosts - * and endpoints. + * the JSON, and returns a [MockConfiguration] object containing all API groups, + * their environments, and shared endpoint definitions. * * The configuration is cached after the first successful load to improve * performance on subsequent calls. @@ -149,17 +153,21 @@ public class MockConfigRepository( * The configuration file should be valid JSON following this structure: * ```json * { - * "hosts": [ + * "apiGroups": [ * { - * "id": "staging", - * "url": "https://staging.api.example.com", + * "id": "my-backend", + * "name": "My Backend", * "endpoints": [ * { * "id": "getUser", - * "name": "Get User Profile", - * "path": "/api/v1/user/{userId}", + * "name": "Get User", + * "path": "/v1/users/{userId}", * "method": "GET" * } + * ], + * "environments": [ + * { "id": "staging", "name": "Staging", "url": "https://staging.api.example.com" }, + * { "id": "production", "name": "Production", "url": "https://api.example.com" } * ] * } * ] @@ -176,7 +184,7 @@ public class MockConfigRepository( public suspend fun loadConfiguration(): Result = runCatching { cachedConfig?.let { println( - message = "[NetworkMock][Config] Using cached configuration with ${it.hosts.size} host(s)" + message = "[NetworkMock][Config] Using cached configuration with ${it.apiGroups.size} group(s)" ) return@runCatching it } @@ -190,12 +198,18 @@ public class MockConfigRepository( cachedConfig = config println(message = "[NetworkMock][Config] Successfully loaded configuration:") - config.hosts.forEach { host -> + config.apiGroups.forEach { group -> println( - message = "[NetworkMock][Config] Host: ${host.id} (${host.url}) " + - "with ${host.endpoints.size} endpoint(s)" + message = "[NetworkMock][Config] Group: ${group.id} " + + "with ${group.endpoints.size} shared endpoint(s) " + + "and ${group.environments.size} environment(s)" ) - host.endpoints.forEach { endpoint -> + group.environments.forEach { env -> + println( + message = "[NetworkMock][Config] Environment: ${env.id} (${env.url})" + ) + } + group.endpoints.forEach { endpoint -> println( message = "[NetworkMock][Config] - ${endpoint.method} ${endpoint.path} (${endpoint.id})" ) @@ -213,23 +227,32 @@ public class MockConfigRepository( /** * Finds a matching endpoint configuration for an incoming HTTP request. * - * This method performs the following matching steps: - * 1. Extract hostname from the host parameter - * 2. Find a [com.worldline.devview.networkmock.model.HostConfig] with matching hostname (case-insensitive) - * 3. For each endpoint in that host, check if the path and method match - * 4. Use [RequestMatcher] to handle path parameters (e.g., `/users/{userId}`) + * The environment is derived entirely from the request URL — there is no stored + * active environment selection. This allows the app to simultaneously target + * different environments for different API groups without any manual switching. + * + * ## Matching Steps + * 1. Iterate over all [com.worldline.devview.networkmock.model.ApiGroupConfig] entries + * 2. For each group, iterate over its [com.worldline.devview.networkmock.model.EnvironmentConfig] entries + * 3. Extract the hostname from [com.worldline.devview.networkmock.model.EnvironmentConfig.url] + * and compare against the request host (case-insensitive) + * 4. On a hostname match, build the effective endpoint list for that group+environment + * via [ApiGroupConfig.effectiveEndpoints] (shared pool + overrides + additions) + * 5. Match the request path and method against the effective endpoint list + * 6. Return a [MockMatch] carrying [MockMatch.groupId], [MockMatch.environmentId], + * [MockMatch.endpointId], and the resolved [MockMatch.config] * * ## Matching Rules - * - **Host matching**: Compares hostnames (case-insensitive) - * - **Path matching**: Uses [RequestMatcher.matchesPath] for parameter support + * - **Host matching**: Compares hostnames extracted from URLs (case-insensitive) + * - **Path matching**: Uses [RequestMatcher.matchesPath] for path parameter support * - **Method matching**: Exact match (case-sensitive) * - * @param host The request hostname (e.g., "staging.api.example.com") - * @param path The request path (e.g., "/api/v1/user/123") - * @param method The HTTP method (e.g., "GET", "POST") + * @param host The request hostname (e.g., `"staging.api.example.com"`) + * @param path The request path (e.g., `"/v1/users/123"`) + * @param method The HTTP method (e.g., `"GET"`, `"POST"`) * @return A [MockMatch] if a matching endpoint is found, or `null` otherwise */ - @Suppress("ReturnCount") + @Suppress("ReturnCount", "LongMethod") public suspend fun findMatchingMock(host: String, path: String, method: String): MockMatch? { println(message = "[NetworkMock][Matching] Looking for match: $method $host$path") @@ -239,156 +262,278 @@ public class MockConfigRepository( return null } - println(message = "[NetworkMock][Matching] Comparing against ${config.hosts.size} host(s):") - val matchingHost = config.hosts.firstOrNull { hostConfig -> - val configHostname = extractHostname(url = hostConfig.url) - val matches = configHostname.equals(other = host, ignoreCase = true) - println( - message = "[NetworkMock][Matching] Host '${hostConfig.id}': $configHostname vs $host = $matches" - ) - matches - } - - if (matchingHost == null) { - println(message = "[NetworkMock][Matching] ERROR: No matching host found for '$host'") - return null - } - println( - message = "[NetworkMock][Matching] Host matched: '${matchingHost.id}' - " + - "checking ${matchingHost.endpoints.size} endpoint(s)" + message = "[NetworkMock][Matching] Comparing against ${config.apiGroups.size} group(s):" ) - val matchingEndpoint = matchingHost.endpoints.firstOrNull { endpoint -> - val pathMatches = RequestMatcher.matchesPath( - configPath = endpoint.path, - requestPath = path - ) - val methodMatches = endpoint.method == method - println(message = "[NetworkMock][Matching] Endpoint '${endpoint.id}':") - println( - message = "[NetworkMock][Matching] Path: ${endpoint.path} vs $path = $pathMatches" - ) - println( - message = "[NetworkMock][Matching] Method: ${endpoint.method} vs $method = $methodMatches" - ) - pathMatches && methodMatches - } + for (group in config.apiGroups) { + println(message = "[NetworkMock][Matching] Group '${group.id}':") + for (environment in group.environments) { + val configHostname = extractHostname(url = environment.url) + val hostMatches = configHostname.equals(other = host, ignoreCase = true) + println( + message = "[NetworkMock][Matching] Environment '${environment.id}': " + + "$configHostname vs $host = $hostMatches" + ) - if (matchingEndpoint == null) { - println( - message = "[NetworkMock][Matching] ERROR: No matching endpoint found for $method $path" - ) - return null - } + if (!hostMatches) continue - println( - message = - "[NetworkMock][Matching] SUCCESS: Matched endpoint '${matchingEndpoint.id}' " + - "on host '${matchingHost.id}'" - ) + println( + message = "[NetworkMock][Matching] Host matched — " + + "resolving effective endpoints for '${group.id}/${environment.id}'" + ) - return MockMatch( - hostId = matchingHost.id, - endpointId = matchingEndpoint.id, - config = matchingEndpoint - ) + val effectiveEndpoints = group.effectiveEndpoints(environment = environment) + println( + message = "[NetworkMock][Matching] Checking ${effectiveEndpoints.size} effective endpoint(s)" + ) + + val matchingEndpoint = effectiveEndpoints.firstOrNull { endpoint -> + val pathMatches = RequestMatcher.matchesPath( + configPath = endpoint.path, + requestPath = path + ) + val methodMatches = endpoint.method == method + println(message = "[NetworkMock][Matching] Endpoint '${endpoint.id}':") + println( + message = "[NetworkMock][Matching] Path: ${endpoint.path} vs " + + "$path = $pathMatches" + ) + println( + message = "[NetworkMock][Matching] Method: ${endpoint.method} vs " + + "$method = $methodMatches" + ) + pathMatches && methodMatches + } + + if (matchingEndpoint != null) { + println( + message = "[NetworkMock][Matching] SUCCESS: Matched endpoint " + + "'${matchingEndpoint.id}' in group '${group.id}', " + + "environment '${environment.id}'" + ) + return MockMatch( + key = EndpointKey( + groupId = group.id, + environmentId = environment.id, + endpointId = matchingEndpoint.id + ), + config = matchingEndpoint + ) + } + + println( + message = "[NetworkMock][Matching] ERROR: No matching endpoint found " + + "for $method $path in group '${group.id}', environment '${environment.id}'" + ) + } + } + + println(message = "[NetworkMock][Matching] ERROR: No match found for $method $host$path") + return null } /** - * Discovers available mock response files for a specific endpoint. + * Discovers available mock response files for a specific group, environment, and endpoint. * - * This method attempts to load response files following the naming convention: - * - Format: `{endpointId}-{statusCode}[-{suffix}].json` - * - Location: `files/networkmocks/responses/{endpointId}/` + * Uses a two-tier resolution strategy — environment-specific files take priority over + * shared fallback files. For each status code in [statusCodesToDiscover] and each + * known suffix, the method first tries the environment-specific path, then the shared + * path. Results are merged and deduplicated by file name, with environment-specific + * files winning on any conflict. * - * The method tries each status code in [statusCodesToDiscover] and returns all - * successfully loaded response files as [MockResponse] objects. + * ## Resolution Order + * For each candidate file name: + * 1. `files/networkmocks/responses/{groupId}/{environmentId}/{endpointId}/{fileName}` ← tried first + * 2. `files/networkmocks/responses/{groupId}/{endpointId}/{fileName}` ← fallback * * ## Naming Convention * - `getUser-200.json` → Success response - * - `getUser-404-simple.json` → Not found with simple error - * - `getUser-404-detailed.json` → Not found with detailed error + * - `getUser-404-simple.json` → Not found with simple error body + * - `getUser-404-detailed.json` → Not found with detailed error body * - `getUser-500.json` → Server error * - * ## Discovery Strategy - * Since we cannot list directory contents in Compose Resources, we try to - * load files with the status codes in [statusCodesToDiscover]. - * For each status code, we also try common suffixes like "simple", "detailed", - * "error", etc. + * @param key The [EndpointKey] identifying the group, environment, and endpoint + * @return A deduplicated, status-code-sorted list of discovered [MockResponse] objects + * (may be empty if no files are found in either location) + */ + public suspend fun discoverResponseFiles(key: EndpointKey): List = + discoverResponseFiles( + groupId = key.groupId, + environmentId = key.environmentId, + endpointId = key.endpointId + ) + + /** + * Discovers available mock response files for a specific group, environment, and endpoint. + * + * Uses a two-tier resolution strategy — environment-specific files take priority over + * shared fallback files. For each status code in [statusCodesToDiscover] and each + * known suffix, the method first tries the environment-specific path, then the shared + * path. Results are merged and deduplicated by file name, with environment-specific + * files winning on any conflict. * - * @param endpointId The endpoint identifier (e.g., "getUser", "createPost") - * @return A list of discovered [MockResponse] objects (may be empty) + * ## Resolution Order + * For each candidate file name: + * 1. `files/networkmocks/responses/{groupId}/{environmentId}/{endpointId}/{fileName}` ← tried first + * 2. `files/networkmocks/responses/{groupId}/{endpointId}/{fileName}` ← fallback + * + * ## Naming Convention + * - `getUser-200.json` → Success response + * - `getUser-404-simple.json` → Not found with simple error body + * - `getUser-404-detailed.json` → Not found with detailed error body + * - `getUser-500.json` → Server error + * + * @param groupId The [com.worldline.devview.networkmock.model.ApiGroupConfig] identifier + * @param environmentId The [com.worldline.devview.networkmock.model.EnvironmentConfig] identifier + * @param endpointId The [com.worldline.devview.networkmock.model.EndpointConfig] identifier + * @return A deduplicated, status-code-sorted list of discovered [MockResponse] objects + * (may be empty if no files are found in either location) */ - public suspend fun discoverResponseFiles(endpointId: String): List { + public suspend fun discoverResponseFiles( + groupId: String, + environmentId: String, + endpointId: String + ): List { println( - message = "[NetworkMock][Discovery] Discovering response files for endpoint: $endpointId" + message = "[NetworkMock][Discovery] Discovering response files for " + + "$groupId/$environmentId/$endpointId" ) - val responses = mutableListOf() - val basePath = "files/networkmocks/responses/$endpointId" + + val environmentPath = "files/networkmocks/responses/$groupId/$environmentId/$endpointId" + val sharedPath = "files/networkmocks/responses/$groupId/$endpointId" // TODO - Consider making suffixes configurable if needed by integrators - val suffixesToTry = listOf( - "", - "-simple", - "-detailed", - "-error", - "-success" - ) + val suffixesToTry = listOf("", "-simple", "-detailed", "-error", "-success") + + // Use a LinkedHashMap keyed by fileName so that environment-specific entries + // automatically win over shared ones when both exist for the same file name. + val discovered = linkedMapOf() for (statusCode in statusCodesToDiscover) { for (suffix in suffixesToTry) { val fileName = "$endpointId-$statusCode$suffix.json" - val filePath = "$basePath/$fileName" - val response = loadMockResponseFromPath(filePath = filePath, fileName = fileName) - if (response != null) { + // Tier 1 — environment-specific + val envResponse = loadMockResponseFromPath( + filePath = "$environmentPath/$fileName", + fileName = fileName + ) + if (envResponse != null) { println( - message = "[NetworkMock][Discovery] Found: $fileName (status ${response.statusCode})" + message = "[NetworkMock][Discovery] Found (env-specific): $fileName " + + "(status ${envResponse.statusCode})" ) - responses.add(element = response) + discovered[fileName] = envResponse + continue + } + + // Tier 2 — shared fallback + val sharedResponse = loadMockResponseFromPath( + filePath = "$sharedPath/$fileName", + fileName = fileName + ) + if (sharedResponse != null) { + println( + message = "[NetworkMock][Discovery] Found (shared): $fileName " + + "(status ${sharedResponse.statusCode})" + ) + discovered[fileName] = sharedResponse } } } println( - message = "[NetworkMock][Discovery] Discovered ${responses.size} response file(s) for '$endpointId'" + message = "[NetworkMock][Discovery] Discovered ${discovered.size} response file(s) " + + "for '$groupId/$environmentId/$endpointId'" ) - return responses.sortedBy { it.statusCode } + return discovered.values.sortedBy { it.statusCode } } /** - * Loads a specific mock response file by endpoint ID and filename. + * Loads a specific mock response file for a given endpoint key. * - * This method loads a response file from the expected location: - * `files/networkmocks/responses/{endpointId}/{fileName}` + * Convenience overload of [loadMockResponse] that accepts an [EndpointKey] instead of + * three separate string identifiers. Delegates directly to the three-param overload. * - * @param endpointId The endpoint identifier (e.g., "getUser") - * @param fileName The response filename (e.g., "getUser-200.json") - * @return A [MockResponse] if successful, or `null` on error + * @param key The [EndpointKey] identifying the group, environment, and endpoint + * @param fileName The response filename (e.g., `"getUser-200.json"`) + * @return A [MockResponse] if the file is found in either location, or `null` on error */ - public suspend fun loadMockResponse(endpointId: String, fileName: String): MockResponse? { - println(message = "[NetworkMock][Loading] Loading response: $endpointId/$fileName") - val filePath = "files/networkmocks/responses/$endpointId/$fileName" - val response = loadMockResponseFromPath(filePath = filePath, fileName = fileName) - if (response != null) { + public suspend fun loadMockResponse(key: EndpointKey, fileName: String): MockResponse? = + loadMockResponse( + groupId = key.groupId, + environmentId = key.environmentId, + endpointId = key.endpointId, + fileName = fileName + ) + + /** + * Loads a specific mock response file for a given group, environment, and endpoint. + * + * Uses the same two-tier resolution strategy as [discoverResponseFiles]: + * the environment-specific path is tried first, and the shared fallback path + * is used if the file is not found there. + * + * ## Resolution Order + * 1. `files/networkmocks/responses/{groupId}/{environmentId}/{endpointId}/{fileName}` ← tried first + * 2. `files/networkmocks/responses/{groupId}/{endpointId}/{fileName}` ← fallback + * + * @param groupId The [com.worldline.devview.networkmock.model.ApiGroupConfig] identifier + * @param environmentId The [com.worldline.devview.networkmock.model.EnvironmentConfig] identifier + * @param endpointId The [com.worldline.devview.networkmock.model.EndpointConfig] identifier + * @param fileName The response filename (e.g., `"getUser-200.json"`) + * @return A [MockResponse] if the file is found in either location, or `null` on error + */ + public suspend fun loadMockResponse( + groupId: String, + environmentId: String, + endpointId: String, + fileName: String + ): MockResponse? { + println( + message = "[NetworkMock][Loading] Loading response: $groupId/$environmentId/$endpointId/$fileName" + ) + + // Tier 1 — environment-specific + val envPath = "files/networkmocks/responses/$groupId/$environmentId/$endpointId/$fileName" + val envResponse = loadMockResponseFromPath(filePath = envPath, fileName = fileName) + if (envResponse != null) { + println( + message = "[NetworkMock][Loading] Successfully loaded (env-specific): $fileName " + + "(status ${envResponse.statusCode})" + ) + return envResponse + } + + // Tier 2 — shared fallback + val sharedPath = "files/networkmocks/responses/$groupId/$endpointId/$fileName" + val sharedResponse = loadMockResponseFromPath(filePath = sharedPath, fileName = fileName) + if (sharedResponse != null) { println( - message = "[NetworkMock][Loading] Successfully loaded: $fileName (status ${response.statusCode})" + message = "[NetworkMock][Loading] Successfully loaded (shared): $fileName " + + "(status ${sharedResponse.statusCode})" ) - } else { - println(message = "[NetworkMock][Loading] ERROR: Failed to load: $fileName") + return sharedResponse } - return response + + println( + message = "[NetworkMock][Loading] ERROR: Failed to load '$fileName' from " + + "either '$envPath' or '$sharedPath'" + ) + return null } /** * Loads a mock response from a specific file path. * * Internal helper used by both [loadMockResponse] and [discoverResponseFiles]. + * Returns `null` silently on any I/O error — callers treat `null` as "file not found" + * and fall through to the next resolution tier. * - * @param filePath The full path to the response file - * @param fileName The filename (used for parsing and display) - * @return A [MockResponse] if successful, or `null` on error + * @param filePath The full path to the response file relative to composeResources + * @param fileName The filename (used for status code parsing and display name generation) + * @return A [MockResponse] if the file exists and parses successfully, or `null` otherwise */ @Suppress("DocumentationOverPrivateFunction") private suspend fun loadMockResponseFromPath( diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/repository/MockStateRepository.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/repository/MockStateRepository.kt index 09e971e..9a820d6 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/repository/MockStateRepository.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/repository/MockStateRepository.kt @@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import com.worldline.devview.networkmock.model.EndpointKey import com.worldline.devview.networkmock.model.EndpointMockState import com.worldline.devview.networkmock.model.NetworkMockState import kotlin.time.Clock @@ -22,8 +23,8 @@ import okio.IOException * Repository for persisting and retrieving network mock state using DataStore. * * This repository manages the runtime state of the network mock feature, including - * the global mocking toggle and individual endpoint configurations. All state changes - * are persisted to DataStore and survive app restarts. + * the global mocking toggle, the active environment selection, and individual endpoint + * configurations. All state changes are persisted to DataStore and survive app restarts. * * ## Responsibilities * - Persist [NetworkMockState] to DataStore Preferences @@ -32,14 +33,24 @@ import okio.IOException * - Update individual endpoint states * - Handle reset operations * + * ## Current Implementation Status + * All write-path APIs accept an [EndpointKey] as the primary overload. The three separate + * string parameters (`groupId`, `environmentId`, `endpointId`) are still accepted as a + * convenience overload that delegates to the [EndpointKey] variant, so existing call sites + * continue to compile without changes. + * * ## DataStore Schema * Each piece of state is stored under its own typed preference key: * - `network_mock_global_enabled`: Boolean — global mocking toggle * - `network_mock_last_modified`: Long — timestamp of last modification - * - `network_mock_endpoint_{hostId}-{endpointId}`: String — JSON-serialized + * - `network_mock_endpoint_{groupId}-{environmentId}-{endpointId}`: String — JSON-serialized * [EndpointMockState] for each individual endpoint, stored separately so * that updating one endpoint does not affect others * + * There is no stored active environment — the environment is derived at runtime by matching + * the incoming request's hostname against configured environment URLs, allowing the app to + * simultaneously target different environments for different API groups. + * * An in-memory registry of known endpoint keys is maintained alongside the * DataStore to allow enumeration of all endpoints without scanning all keys. * @@ -72,7 +83,12 @@ import okio.IOException * if (!currentState.globalMockingEnabled) { * return@intercept execute(requestBuilder) * } - * // ... check endpoint states + * val match = mockRepository.findMatchingMock( + * host = host, + * path = path, + * method = method + * ) + * // ... * } * ``` * @@ -83,8 +99,7 @@ import okio.IOException * * // Configure endpoint * repository.setEndpointMockState( - * hostId = "staging", - * endpointId = "getUser", + * key = EndpointKey("my-backend", "staging", "getUser"), * state = EndpointMockState.Mock(responseFile = "getUser-200.json") * ) * @@ -109,7 +124,7 @@ public class MockStateRepository(private val dataStore: DataStore) /** * In-memory registry of known endpoint preference keys. * - * Maps `"{hostId}-{endpointId}"` to its corresponding [Preferences.Key]. + * Maps `"{groupId}-{environmentId}-{endpointId}"` to its corresponding [Preferences.Key]. * Populated as endpoints are written to DataStore. Used to enumerate all * known endpoints without scanning all DataStore keys. */ @@ -125,20 +140,17 @@ public class MockStateRepository(private val dataStore: DataStore) } /** - * Returns the [Preferences.Key] for a specific endpoint, creating and - * registering it in [endpointKeys] if not already present. + * Returns the [Preferences.Key] for a specific endpoint identified by an [EndpointKey], + * creating and registering it in [endpointKeys] if not already present. * - * @param hostId The host identifier - * @param endpointId The endpoint identifier + * @param key The [EndpointKey] identifying the group, environment, and endpoint * @return The [Preferences.Key] for this endpoint's [EndpointMockState] */ @Suppress("DocumentationOverPrivateFunction") - private fun endpointKey(hostId: String, endpointId: String): Preferences.Key { - val compositeKey = "$hostId-$endpointId" - return endpointKeys.getOrPut(key = compositeKey) { - stringPreferencesKey(name = "$ENDPOINT_KEY_PREFIX$compositeKey") + private fun endpointKey(key: EndpointKey): Preferences.Key = + endpointKeys.getOrPut(key = key.compositeKey) { + stringPreferencesKey(name = "$ENDPOINT_KEY_PREFIX${key.compositeKey}") } - } /** * Observes the network mock state as a reactive [Flow]. @@ -171,21 +183,12 @@ public class MockStateRepository(private val dataStore: DataStore) throw exception } }.map { preferences -> - // Scan ALL keys stored in DataStore that match our endpoint prefix. - // This replaces the previous approach of iterating over the in-memory - // `endpointKeys` registry, which is empty on a cold start because it - // is only populated when a write operation is performed in the current - // session. Without this change, all persisted endpoint states were - // silently ignored on startup and every endpoint appeared as Network. val endpointStates = preferences .asMap() .entries .filter { (key, _) -> key.name.startsWith(prefix = ENDPOINT_KEY_PREFIX) } .associate { (key, rawValue) -> val compositeKey = key.name.removePrefix(prefix = ENDPOINT_KEY_PREFIX) - // Keep the in-memory registry in sync so write-side helpers - // (setEndpointMockState, resetKnownEndpointsToNetwork, etc.) - // remain consistent for the rest of the session. endpointKeys.getOrPut(key = compositeKey) { stringPreferencesKey(name = key.name) } @@ -242,43 +245,74 @@ public class MockStateRepository(private val dataStore: DataStore) } /** - * Sets the mock state for a specific endpoint. + * Sets the mock state for a specific endpoint identified by an [EndpointKey]. * * The endpoint state is stored under its own individual preference key - * (`network_mock_endpoint_{hostId}-{endpointId}`), so updating one endpoint - * does not affect any other endpoint's stored state. + * (`network_mock_endpoint_{groupId}-{environmentId}-{endpointId}`), so updating one + * endpoint does not affect any other endpoint's stored state. Each group+environment + * combination maintains independent state for the same endpoint ID. * * ```kotlin * repository.setEndpointMockState( - * hostId = "staging", - * endpointId = "getUser", + * key = EndpointKey("my-backend", "staging", "getUser"), * state = EndpointMockState.Mock(responseFile = "getUser-200.json") * ) * ``` * - * @param hostId The host identifier (e.g., "staging", "production") - * @param endpointId The endpoint identifier (e.g., "getUser", "createPost") + * @param key The [EndpointKey] identifying the group, environment, and endpoint * @param state The new endpoint mock state */ - public suspend fun setEndpointMockState( - hostId: String, - endpointId: String, - state: EndpointMockState - ) { + public suspend fun setEndpointMockState(key: EndpointKey, state: EndpointMockState) { val stateDescription = when (state) { is EndpointMockState.Network -> "network" is EndpointMockState.Mock -> "mock, file=${state.responseFile}, status=${state.statusCode}" } println( - message = "[NetworkMock][State] Setting endpoint state: $hostId-$endpointId, $stateDescription" + message = "[NetworkMock][State] Setting endpoint state: ${key.compositeKey}, $stateDescription" ) - val key = endpointKey(hostId = hostId, endpointId = endpointId) + val prefKey = endpointKey(key = key) dataStore.edit { preferences -> - preferences[key] = json.encodeToString(value = state) + preferences[prefKey] = json.encodeToString(value = state) preferences[KEY_LAST_MODIFIED] = Clock.System.now().toEpochMilliseconds() } } + /** + * Sets the mock state for a specific endpoint in a specific group and environment. + * + * Convenience overload of [setEndpointMockState] that accepts three separate string + * identifiers instead of an [EndpointKey]. Delegates to the [EndpointKey] overload. + * + * ```kotlin + * repository.setEndpointMockState( + * groupId = "my-backend", + * environmentId = "staging", + * endpointId = "getUser", + * state = EndpointMockState.Mock(responseFile = "getUser-200.json") + * ) + * ``` + * + * @param groupId The [com.worldline.devview.networkmock.model.ApiGroupConfig] identifier + * @param environmentId The [com.worldline.devview.networkmock.model.EnvironmentConfig] identifier + * @param endpointId The [com.worldline.devview.networkmock.model.EndpointConfig] identifier + * @param state The new endpoint mock state + */ + public suspend fun setEndpointMockState( + groupId: String, + environmentId: String, + endpointId: String, + state: EndpointMockState + ) { + setEndpointMockState( + key = EndpointKey( + groupId = groupId, + environmentId = environmentId, + endpointId = endpointId + ), + state = state + ) + } + /** * Overwrites the stored endpoint states with the provided map. * @@ -293,16 +327,15 @@ public class MockStateRepository(private val dataStore: DataStore) * - Endpoint states: Each entry written to its own key * - Last modified timestamp: Updated to current time * - * @param states Map of `"{hostId}-{endpointId}"` keys to [EndpointMockState] values + * @param states Map of [EndpointKey] identifiers to [EndpointMockState] values */ - public suspend fun setAllEndpointStates(states: Map) { + public suspend fun setAllEndpointStates(states: Map) { println( message = "[NetworkMock][State] Setting all endpoint states (${states.size} entries)" ) dataStore.edit { preferences -> - states.forEach { (compositeKey, state) -> - val (hostId, endpointId) = compositeKey.split("-", limit = 2) - val key = endpointKey(hostId = hostId, endpointId = endpointId) + states.forEach { (endpointKey, state) -> + val key = endpointKey(key = endpointKey) preferences[key] = json.encodeToString(value = state) } preferences[KEY_LAST_MODIFIED] = Clock.System.now().toEpochMilliseconds() @@ -352,12 +385,12 @@ public class MockStateRepository(private val dataStore: DataStore) * Call this once, immediately after the mock configuration has been loaded * (before any writes). * - * @param endpoints List of `(hostId, endpointId)` pairs from the loaded + * @param endpoints List of [EndpointKey] values from the loaded * [com.worldline.devview.networkmock.model.MockConfiguration] */ - public fun registerEndpoints(endpoints: List>) { - endpoints.forEach { (hostId, endpointId) -> - endpointKey(hostId = hostId, endpointId = endpointId) + public fun registerEndpoints(endpoints: List) { + endpoints.forEach { key -> + endpointKey(key = key) } } } diff --git a/devview-networkmock-ktor/build.gradle.kts b/devview-networkmock-ktor/build.gradle.kts index e999acc..02df8d0 100644 --- a/devview-networkmock-ktor/build.gradle.kts +++ b/devview-networkmock-ktor/build.gradle.kts @@ -8,7 +8,7 @@ plugins { kotlin { addDefaultDevViewTargets() - androidLibrary { + android { namespace = "com.worldline.devview.networkmock.ktor" } diff --git a/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/plugin/NetworkMockPlugin.kt b/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/plugin/NetworkMockPlugin.kt index d98601c..0560249 100644 --- a/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/plugin/NetworkMockPlugin.kt +++ b/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/plugin/NetworkMockPlugin.kt @@ -84,25 +84,30 @@ public data class NetworkMockPluginConfig(internal val config: NetworkMockConfig * Create `composeResources/files/networkmocks/mocks.json`: * ```json * { - * "hosts": [{ - * "id": "staging", - * "url": "https://staging.api.example.com", + * "apiGroups": [{ + * "id": "my-backend", + * "name": "My Backend", * "endpoints": [{ * "id": "getUser", * "name": "Get User Profile", - * "path": "/api/v1/user/{userId}", + * "path": "/v1/users/{userId}", * "method": "GET" - * }] + * }], + * "environments": [ + * { "id": "staging", "name": "Staging", "url": "https://staging.api.example.com" }, + * { "id": "production", "name": "Production", "url": "https://api.example.com" } + * ] * }] * } * ``` * * Add response files following the naming convention: * ``` - * composeResources/files/networkmocks/responses/getUser/ - * ├── getUser-200.json - * ├── getUser-404.json - * └── getUser-500.json + * composeResources/files/networkmocks/responses/my-backend/getUser/ + * ├── getUser-200.json (shared — used by all environments) + * └── getUser-404.json + * composeResources/files/networkmocks/responses/my-backend/staging/getUser/ + * └── getUser-200.json (staging-specific — overrides the shared variant) * ``` * * ## Error Handling @@ -165,15 +170,15 @@ public val NetworkMockPlugin: HttpClientPlugin println( - message = "$LOG_PREFIX Found matching endpoint: ${match.hostId}-${match.endpointId}" + message = "$LOG_PREFIX Found matching endpoint: " + + "${match.groupId}/${match.environmentId}/${match.endpointId}" ) - val endpointKey = "${match.hostId}-${match.endpointId}" - val endpointState = currentState.endpointStates[endpointKey] + val endpointState = currentState.getEndpointState(key = match.key) if (endpointState == null) { println( - message = "$LOG_PREFIX No state found for endpoint key: $endpointKey" + message = "$LOG_PREFIX No state found for endpoint key: ${match.key.compositeKey}" ) println( message = "$LOG_PREFIX Available endpoint states: ${currentState.endpointStates.keys}" @@ -214,7 +219,7 @@ public val NetworkMockPlugin: HttpClientPlugin = persistentMapOf( - NetworkMockDestination.Main.withTitle(title = "Network Mock") { - action(icon = Icons.Rounded.Restore) { - onResetToNetwork.tryEmit(value = Unit) - } - } - ) + override val destinations: PersistentMap, DestinationMetadata> = + persistentMapOf( + NetworkMockDestination.Main.withTitle(title = "Network Mock") { + action(icon = Icons.Rounded.Restore) { + onResetToNetwork.tryEmit(value = Unit) + } + }, + NetworkMockDestination.Endpoint::class.withTitle(title = "Endpoint Details") + ) + + override val entryDestination: NavKey = NetworkMockDestination.Main override val registerSerializers: PolymorphicModuleBuilder.() -> Unit get() = { @@ -121,6 +133,10 @@ public class NetworkMock( subclass = NetworkMockDestination.Main::class, serializer = NetworkMockDestination.Main.serializer() ) + subclass( + subclass = NetworkMockDestination.Endpoint::class, + serializer = NetworkMockDestination.Endpoint.serializer() + ) } private val onResetToNetwork = MutableSharedFlow( @@ -137,10 +153,32 @@ public class NetworkMock( NetworkMockScreen( modifier = Modifier .fillMaxSize(), - configRepository = NetworkMockInitializer.requireConfigRepository(), - stateRepository = NetworkMockInitializer.requireStateRepository(), + viewModel = viewModel { + NetworkMockViewModel( + configRepository = NetworkMockInitializer.requireConfigRepository(), + stateRepository = NetworkMockInitializer.requireStateRepository() + ) + }, bottomPadding = bottomPadding, - resetToNetworkSharedFlow = onResetToNetwork + resetToNetworkSharedFlow = onResetToNetwork, + navigateToEndpointScreen = { endpointKey -> + onNavigate(NetworkMockDestination.Endpoint(endpointKey = endpointKey)) + } + ) + } + + entry { + NetworkMockEndpointScreen( + modifier = Modifier + .fillMaxSize(), + viewModel = viewModel { + NetworkMockEndpointViewModel( + endpointKey = it.endpointKey, + configRepository = NetworkMockInitializer.requireConfigRepository(), + stateRepository = NetworkMockInitializer.requireStateRepository() + ) + }, + bottomPadding = bottomPadding ) } } diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockEndpointBottomSheet.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockEndpointBottomSheet.kt deleted file mode 100644 index 83ab511..0000000 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockEndpointBottomSheet.kt +++ /dev/null @@ -1,220 +0,0 @@ -package com.worldline.devview.networkmock - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import com.worldline.devview.networkmock.components.MockItem -import com.worldline.devview.networkmock.components.NetworkItem -import com.worldline.devview.networkmock.model.EndpointDescriptor -import com.worldline.devview.networkmock.model.EndpointMockState -import com.worldline.devview.networkmock.model.StatusCodeFamily -import com.worldline.devview.networkmock.preview.EndpointUiModelPreviewParameterProvider -import com.worldline.devview.networkmock.viewmodel.EndpointUiModel -import kotlinx.coroutines.launch - -@Composable -internal fun NetworkMockEndpointBottomSheet( - descriptor: EndpointDescriptor, - currentState: EndpointMockState, - onSelectResponse: (responseFileName: String?) -> Unit, - onDismissRequest: () -> Unit = {}, - modifier: Modifier = Modifier -) { - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) - val scope = rememberCoroutineScope() - - val groupedResponses = descriptor.availableResponses.groupBy { - StatusCodeFamily.fromStatusCode(statusCode = it.statusCode) - } - - val selectedResponse = when (currentState) { - is EndpointMockState.Mock -> descriptor.availableResponses.find { - it.fileName == currentState.responseFile - } - EndpointMockState.Network -> null - } - - ModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = sheetState, - modifier = modifier - ) { - Column( - modifier = modifier - .fillMaxWidth() - ) { - Box( - modifier = Modifier - .fillMaxWidth() - ) { - Text( - modifier = Modifier - .align(alignment = Alignment.Center), - text = descriptor.config.name, - style = MaterialTheme.typography.titleMedium - ) - - IconButton( - modifier = Modifier - .align(alignment = Alignment.CenterEnd), - onClick = { - scope - .launch { - sheetState.hide() - }.invokeOnCompletion { - if (!sheetState.isVisible) { - onDismissRequest() - } - } - } - ) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = null - ) - } - } - LazyColumn( - modifier = Modifier - .fillMaxWidth() - ) { - stickyHeader( - key = "network_header" - ) { - Text( - modifier = Modifier - .fillMaxWidth() - .background(color = MaterialTheme.colorScheme.surfaceContainerLow) - .padding( - horizontal = 16.dp, - vertical = 8.dp - ), - text = "No mock".uppercase(), - style = MaterialTheme.typography.labelLarge - ) - } - item( - key = "network_item" - ) { - NetworkItem( - modifier = Modifier - .background(color = MaterialTheme.colorScheme.background) - .padding( - horizontal = 16.dp - ), - selected = selectedResponse == null, - onClick = { - onSelectResponse(null) - } - ) - } - - item( - key = "network_spacer" - ) { - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(height = 16.dp) - ) - } - groupedResponses.forEach { (statusCodeFamily, mockResponses) -> - stickyHeader( - key = "header_${statusCodeFamily.name.lowercase()}" - ) { - Text( - modifier = Modifier - .fillMaxWidth() - .background(color = MaterialTheme.colorScheme.surfaceContainerLow) - .padding( - horizontal = 16.dp, - vertical = 8.dp - ), - text = "${statusCodeFamily.displayName} mocks".uppercase(), - style = MaterialTheme.typography.labelLarge - ) - } - itemsIndexed( - items = mockResponses, - key = { _, mockResponse -> "mock_item_${mockResponse.fileName}" } - ) { index, mockResponse -> - MockItem( - mockResponse = mockResponse, - modifier = Modifier - .background(color = MaterialTheme.colorScheme.background) - .padding( - horizontal = 16.dp - ), - selected = selectedResponse?.fileName == mockResponse.fileName, - onClick = { - onSelectResponse(mockResponse.fileName) - } - ) - - if (index != mockResponses.lastIndex) { - HorizontalDivider( - modifier = Modifier - .background(color = MaterialTheme.colorScheme.background) - .padding( - start = 64.dp - ) - ) - } - } - item( - key = "${statusCodeFamily.name.lowercase()}_spacer" - ) { - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(height = 16.dp) - ) - } - } - } - } - } -} - -@Preview(locale = "en") -@Composable -private fun NetworkMockEndpointBottomSheetPreview( - @PreviewParameter( - provider = EndpointUiModelPreviewParameterProvider::class - ) endpointUiModel: EndpointUiModel -) { - MaterialTheme { - Surface { - NetworkMockEndpointBottomSheet( - descriptor = endpointUiModel.descriptor, - currentState = endpointUiModel.currentState, - onSelectResponse = {} - ) - } - } -} diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockEndpointPreviewBottomSheet.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockEndpointPreviewBottomSheet.kt new file mode 100644 index 0000000..59eb378 --- /dev/null +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockEndpointPreviewBottomSheet.kt @@ -0,0 +1,199 @@ +package com.worldline.devview.networkmock + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.worldline.devview.networkmock.components.EndpointStateChip +import com.worldline.devview.networkmock.components.InlineDiffContent +import com.worldline.devview.networkmock.components.SplitDiffContent +import com.worldline.devview.networkmock.model.EndpointMockState +import com.worldline.devview.networkmock.model.MockResponse +import com.worldline.devview.networkmock.preview.PreviewSheetStatePreviewParameterProvider +import kotlinx.coroutines.launch + +/** + * A modal bottom sheet that displays a preview of one or two [MockResponse] payloads for a + * network mock endpoint. + * + * When [previewSheetState] is [PreviewSheetState.Single], the sheet renders the single response + * content using a split-diff view with no right-hand side. When it is [PreviewSheetState.Compare], + * the two responses are diffed against each other: an inline diff is shown when the contents are + * similar enough (as determined by [PreviewSheetState.Compare.useInlineDiff]), otherwise a + * side-by-side split diff is used. + * + * @param previewSheetState The current state of the preview sheet, holding either one or two + * responses to display. + * @param onDismissRequest Called when the sheet should be dismissed (e.g. the user swipes it down + * or taps outside). + * @param modifier [Modifier] to be applied to the [ModalBottomSheet]. + */ +@Composable +internal fun NetworkMockEndpointPreviewBottomSheet( + previewSheetState: PreviewSheetState.HasResponse, + onDismissRequest: () -> Unit = {}, + modifier: Modifier = Modifier +) { + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + val scope = rememberCoroutineScope() + + val currentOnDismissRequest by rememberUpdatedState(newValue = onDismissRequest) + + val onClose: () -> Unit = { + scope + .launch { + sheetState.hide() + }.invokeOnCompletion { + if (!sheetState.isVisible) currentOnDismissRequest() + } + } + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + modifier = modifier + ) { + Column( + modifier = Modifier + .fillMaxWidth() + ) { + PreviewHeader( + previewSheetState = previewSheetState, + onClose = onClose + ) + + when (previewSheetState) { + is PreviewSheetState.Single -> { + SplitDiffContent( + first = previewSheetState.response, + second = null + ) + } + + is PreviewSheetState.Compare -> if (previewSheetState.useInlineDiff) { + InlineDiffContent( + diff = previewSheetState.lineDiff, + leftLabel = previewSheetState.first.fileName, + rightLabel = previewSheetState.second.fileName + ) + } else { + SplitDiffContent( + first = previewSheetState.first, + second = previewSheetState.second + ) + } + } + } + } +} + +/** + * Header bar displayed inside the bottom sheet for either a single preview or a comparison. + * + * When [previewSheetState] is [PreviewSheetState.Single], shows a single [EndpointStateChip] and + * a close button. When it is [PreviewSheetState.Compare], shows two [EndpointStateChip]s separated + * by a "vs" label, a close button, and a colour legend when + * [PreviewSheetState.Compare.useInlineDiff] is `true`. + * + * @param previewSheetState The current preview state, determining which chips are rendered. + * @param onClose Called when the user taps the close button. + * @param modifier [Modifier] to be applied to the root layout. + */ +@Composable +private fun PreviewHeader( + previewSheetState: PreviewSheetState.HasResponse, + onClose: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp, top = 4.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(space = 6.dp) + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterStart + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(space = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + when (previewSheetState) { + is PreviewSheetState.Single -> { + EndpointStateChip( + endpointMockState = EndpointMockState.Mock( + responseFile = previewSheetState.response.fileName + ), + label = previewSheetState.response.displayName + ) + } + + is PreviewSheetState.Compare -> { + EndpointStateChip( + endpointMockState = EndpointMockState.Mock( + responseFile = previewSheetState.first.fileName + ), + label = previewSheetState.first.displayName + ) + Text( + text = "vs", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + EndpointStateChip( + endpointMockState = EndpointMockState.Mock( + responseFile = previewSheetState.second.fileName + ), + label = previewSheetState.second.displayName + ) + } + } + } + IconButton( + modifier = Modifier.align(alignment = Alignment.CenterEnd), + onClick = onClose + ) { + Icon(imageVector = Icons.Rounded.Close, contentDescription = null) + } + } + } +} + +@Preview(locale = "en") +@Composable +private fun NetworkMockEndpointPreviewBottomSheetPreview( + @PreviewParameter( + PreviewSheetStatePreviewParameterProvider::class + ) previewSheetState: PreviewSheetState.HasResponse +) { + MaterialTheme { + Surface { + NetworkMockEndpointPreviewBottomSheet( + previewSheetState = previewSheetState + ) + } + } +} diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockEndpointScreen.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockEndpointScreen.kt new file mode 100644 index 0000000..2af7d90 --- /dev/null +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockEndpointScreen.kt @@ -0,0 +1,369 @@ +package com.worldline.devview.networkmock + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowUpward +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.FabPosition +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.worldline.devview.networkmock.components.EndpointHeaderCard +import com.worldline.devview.networkmock.components.ErrorState +import com.worldline.devview.networkmock.components.LoadingState +import com.worldline.devview.networkmock.components.MockItem +import com.worldline.devview.networkmock.components.NetworkItem +import com.worldline.devview.networkmock.model.DiffLine +import com.worldline.devview.networkmock.model.EndpointMockState +import com.worldline.devview.networkmock.model.EndpointUiModel +import com.worldline.devview.networkmock.model.MockResponse +import com.worldline.devview.networkmock.model.StatusCodeFamily +import com.worldline.devview.networkmock.preview.EndpointUiModelPreviewParameterProvider +import com.worldline.devview.networkmock.utils.INLINE_DIFF_THRESHOLD +import com.worldline.devview.networkmock.utils.computeLineDiff +import com.worldline.devview.networkmock.utils.shouldUseInlineDiff +import com.worldline.devview.networkmock.viewmodel.NetworkMockEndpointUiState +import com.worldline.devview.networkmock.viewmodel.NetworkMockEndpointViewModel +import kotlinx.collections.immutable.PersistentList + +/** + * Detail screen for a single API endpoint, showing all available mock responses and + * allowing the user to activate one or revert to the actual network. + * + * Driven entirely by [viewModel], which is constructed and provided by + * [NetworkMock.registerContent] inside the `entry` lambda + * so that it is scoped to the navigation entry and receives the correct + * [com.worldline.devview.networkmock.model.EndpointKey]. + * + * Renders three possible states from [NetworkMockEndpointViewModel.uiState]: + * - [NetworkMockEndpointUiState.Loading] — shown while mock response files are being discovered + * - [NetworkMockEndpointUiState.Error] — shown if discovery or config lookup fails + * - [NetworkMockEndpointUiState.Content] — the grouped mock list (see [NetworkMockEndpointScreenContent]) + * + * @param viewModel The [NetworkMockEndpointViewModel] scoped to this navigation entry. + * @param modifier Optional modifier for the screen. + * @param bottomPadding Bottom inset padding provided by the DevView [androidx.compose.material3.Scaffold]. + * Applied as [LazyColumn] content padding so the last item is not obscured by system navigation bars. + */ +@Composable +internal fun NetworkMockEndpointScreen( + viewModel: NetworkMockEndpointViewModel, + modifier: Modifier = Modifier, + bottomPadding: Dp = 0.dp +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + when (val state = uiState) { + is NetworkMockEndpointUiState.Loading -> LoadingState(modifier = modifier.fillMaxSize()) + is NetworkMockEndpointUiState.Error -> ErrorState( + message = state.message, + modifier = modifier.fillMaxSize() + ) + + is NetworkMockEndpointUiState.Content -> { + // Stub for Step 3 — will be replaced with the preview sheet + var previewingResponse by remember { mutableStateOf(value = null) } + + NetworkMockEndpointScreenContent( + content = state, + onSelectResponse = viewModel::setMockState, + onPreviewClick = { previewingResponse = it }, + modifier = modifier, + bottomPadding = bottomPadding + ) + } + } +} + +@Composable +private fun NetworkMockEndpointScreenContent( + content: NetworkMockEndpointUiState.Content, + onSelectResponse: (responseFileName: String?) -> Unit, + onPreviewClick: (MockResponse) -> Unit, + modifier: Modifier = Modifier, + bottomPadding: Dp = 0.dp +) { + val endpointUiModel = content.endpointUiModel + val descriptor = endpointUiModel.descriptor + val groupedResponses = descriptor.availableResponses.groupBy { + StatusCodeFamily.fromStatusCode(statusCode = it.statusCode) + } + val selectedResponse = when (val currentState = endpointUiModel.currentState) { + is EndpointMockState.Mock -> + descriptor.availableResponses.find { it.fileName == currentState.responseFile } + + EndpointMockState.Network -> null + } + + var previewSheetState: PreviewSheetState by remember { + mutableStateOf( + value = PreviewSheetState.Hidden + ) + } + + var showPreviewBottomSheet by remember { mutableStateOf(value = false) } + + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = MaterialTheme.colorScheme.surfaceContainer, + floatingActionButton = { + AnimatedVisibility( + visible = previewSheetState != PreviewSheetState.Hidden, + enter = slideInVertically { it * 2 } + fadeIn() + scaleIn(), + exit = slideOutVertically { it * 2 } + fadeOut() + scaleOut() + ) { + FloatingActionButton( + onClick = { + showPreviewBottomSheet = true + } + ) { + Icon( + imageVector = Icons.Rounded.ArrowUpward, + contentDescription = null + ) + } + } + }, + floatingActionButtonPosition = FabPosition.Center + ) { + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(space = 8.dp) + ) { + EndpointHeaderCard( + endpoint = endpointUiModel + ) + ElevatedCard( + modifier = Modifier + .fillMaxWidth(), + elevation = CardDefaults.elevatedCardElevation(), + shape = MaterialTheme.shapes.small + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + text = "Long press a mock response to be able to preview its content", + style = MaterialTheme.typography.bodySmall + ) + } + } + HorizontalDivider() + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.surfaceContainerLow) + ) { + stickyHeader(key = "network_header") { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + text = "No mock".uppercase(), + style = MaterialTheme.typography.labelLarge + ) + } + item(key = "network_item") { + NetworkItem( + modifier = Modifier + .background(color = MaterialTheme.colorScheme.background) + .padding(horizontal = 16.dp), + selected = selectedResponse == null, + onClick = { onSelectResponse(null) } + ) + } + item(key = "network_spacer") { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(height = 16.dp) + ) + } + + groupedResponses.forEach { (statusCodeFamily, mockResponses) -> + stickyHeader(key = "header_${statusCodeFamily.name.lowercase()}") { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + text = "${statusCodeFamily.displayName} mocks".uppercase(), + style = MaterialTheme.typography.labelLarge + ) + } + itemsIndexed( + items = mockResponses, + key = { _, mockResponse -> "mock_item_${mockResponse.fileName}" } + ) { index, mockResponse -> + MockItem( + mockResponse = mockResponse, + modifier = Modifier + .background(color = MaterialTheme.colorScheme.background) + .padding(horizontal = 16.dp), + selected = selectedResponse?.fileName == mockResponse.fileName, + onClick = { onSelectResponse(mockResponse.fileName) }, + onLongClick = { + previewSheetState = + previewSheetState.transition(response = mockResponse) + }, + isInPreviewMode = previewSheetState.isInPreviewMode( + response = mockResponse + ) + ) + if (index != mockResponses.lastIndex) { + HorizontalDivider( + modifier = Modifier + .background(color = MaterialTheme.colorScheme.background) + .padding(start = 64.dp) + ) + } + } + item(key = "${statusCodeFamily.name.lowercase()}_spacer") { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(height = 16.dp) + ) + } + } + + item { + Spacer( + modifier = Modifier + .padding(bottom = bottomPadding) + ) + } + } + } + } + + if (showPreviewBottomSheet && previewSheetState is PreviewSheetState.HasResponse) { + NetworkMockEndpointPreviewBottomSheet( + previewSheetState = previewSheetState as PreviewSheetState.HasResponse, + onDismissRequest = { showPreviewBottomSheet = false } + ) + } +} + +@Immutable +internal sealed interface PreviewSheetState { + @Immutable + sealed interface HasResponse : PreviewSheetState + + /** Sheet is closed. */ + @Immutable + data object Hidden : PreviewSheetState + + /** Sheet shows one response. */ + @Immutable + data class Single(val response: MockResponse) : HasResponse + + /** Sheet shows two responses. */ + @Immutable + data class Compare( + val first: MockResponse, + val second: MockResponse, + val threshold: Float = INLINE_DIFF_THRESHOLD + ) : HasResponse { + val useInlineDiff: Boolean + get() = shouldUseInlineDiff( + contentLeft = first.content, + contentRight = second.content, + threshold = threshold + ) + + val lineDiff: PersistentList + get() = computeLineDiff( + contentLeft = first.content, + contentRight = second.content + ) + } + + fun transition(response: MockResponse): PreviewSheetState = when (this) { + is Hidden -> Single(response = response) + is Single -> if (response == this.response) { + Hidden + } else { + Compare(first = this.response, second = response) + } + + is Compare -> when (response) { + first -> { + Single(response = second) + } + + second -> { + Single(response = first) + } + + else -> { + this + } + } + } + + fun isInPreviewMode(response: MockResponse): Boolean = when (this) { + is Hidden -> false + is Single -> response == this.response + is Compare -> response == first || response == second + } +} + +@Preview(locale = "en") +@Composable +private fun NetworkMockEndpointScreenPreview( + @PreviewParameter( + provider = EndpointUiModelPreviewParameterProvider::class + ) endpointUiModel: EndpointUiModel +) { + MaterialTheme { + Surface { + NetworkMockEndpointScreenContent( + content = NetworkMockEndpointUiState.Content( + endpointUiModel = endpointUiModel + ), + onSelectResponse = {}, + onPreviewClick = {} + ) + } + } +} 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 aa1eeb3..c5bee7c 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 @@ -2,6 +2,7 @@ package com.worldline.devview.networkmock import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -30,17 +31,15 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.worldline.devview.networkmock.components.EmptyState import com.worldline.devview.networkmock.components.EndpointCard import com.worldline.devview.networkmock.components.ErrorState import com.worldline.devview.networkmock.components.GlobalMockToggle import com.worldline.devview.networkmock.components.LoadingState import com.worldline.devview.networkmock.model.EndpointDescriptor +import com.worldline.devview.networkmock.model.EndpointKey import com.worldline.devview.networkmock.model.EndpointMockState import com.worldline.devview.networkmock.preview.NetworkMockUiStatePreviewParameterProvider -import com.worldline.devview.networkmock.repository.MockConfigRepository -import com.worldline.devview.networkmock.repository.MockStateRepository import com.worldline.devview.networkmock.viewmodel.NetworkMockUiState import com.worldline.devview.networkmock.viewmodel.NetworkMockViewModel import kotlinx.coroutines.flow.SharedFlow @@ -54,23 +53,24 @@ import kotlinx.coroutines.flow.SharedFlow * - Select which mock response to return for each endpoint * - Reset all mocks to use actual network * - * @param modifier Optional modifier for the screen - * @param configRepository Repository for loading mock configuration. Should be the shared - * instance constructed by [NetworkMock] so that the plugin and UI use the same cache. - * @param stateRepository Repository for managing mock state (shared instance from integrator) + * @param resetToNetworkSharedFlow Shared flow emitted by [NetworkMock] when the user triggers + * the "Reset to Network" toolbar action. Collected here to call [NetworkMockViewModel.resetAllToNetwork]. + * @param navigateToEndpointScreen Callback invoked when the user taps an [EndpointCard], + * passing the corresponding [EndpointKey] so the caller can push [NetworkMockDestination.Endpoint] + * onto the backstack. + * @param viewModel The [NetworkMockViewModel] instance. Constructed and provided by + * [NetworkMock.registerContent] via the `viewModel { }` factory so that it is scoped to the + * navigation entry. + * @param modifier Optional modifier for the screen. + * @param bottomPadding Bottom inset padding provided by the DevView [androidx.compose.material3.Scaffold]. + * Applied to the endpoint list so the last item is not obscured by system navigation bars. */ @Composable public fun NetworkMockScreen( - configRepository: MockConfigRepository, - stateRepository: MockStateRepository, resetToNetworkSharedFlow: SharedFlow, + navigateToEndpointScreen: (EndpointKey) -> Unit, + viewModel: NetworkMockViewModel, modifier: Modifier = Modifier, - viewModel: NetworkMockViewModel = viewModel { - NetworkMockViewModel( - configRepository = configRepository, - stateRepository = stateRepository - ) - }, bottomPadding: Dp = 0.dp ) { val uiState by viewModel.uiState.collectAsState() @@ -87,7 +87,7 @@ public fun NetworkMockScreen( uiState = uiState, onGlobalToggle = viewModel::setGlobalMockingEnabled, setEndpointMockState = viewModel::setEndpointMockState, - selectEndpoint = viewModel::selectEndpoint, + navigateToEndpointScreen = navigateToEndpointScreen, clearSelectedEndpoint = viewModel::clearSelectedEndpoint, selectedDescriptor = selectedDescriptor, selectedEndpointState = selectedEndpointState, @@ -100,8 +100,8 @@ public fun NetworkMockScreen( private fun NetworkMockScreenContent( uiState: NetworkMockUiState, onGlobalToggle: (Boolean) -> Unit, - setEndpointMockState: (String, String, String?) -> Unit, - selectEndpoint: (String, String) -> Unit, + setEndpointMockState: (EndpointKey, String?) -> Unit, + navigateToEndpointScreen: (EndpointKey) -> Unit, clearSelectedEndpoint: () -> Unit, selectedDescriptor: EndpointDescriptor?, selectedEndpointState: EndpointMockState, @@ -117,7 +117,7 @@ private fun NetworkMockScreenContent( uiState = uiState, onGlobalToggle = onGlobalToggle, setEndpointMockState = setEndpointMockState, - selectEndpoint = selectEndpoint, + openEndpointDetails = navigateToEndpointScreen, clearSelectedEndpoint = clearSelectedEndpoint, selectedDescriptor = selectedDescriptor, selectedEndpointState = selectedEndpointState, @@ -132,8 +132,8 @@ private fun NetworkMockScreenContent( private fun ContentState( uiState: NetworkMockUiState.Content, onGlobalToggle: (Boolean) -> Unit, - setEndpointMockState: (String, String, String?) -> Unit, - selectEndpoint: (String, String) -> Unit, + setEndpointMockState: (EndpointKey, String?) -> Unit, + openEndpointDetails: (EndpointKey) -> Unit, clearSelectedEndpoint: () -> Unit, selectedDescriptor: EndpointDescriptor?, selectedEndpointState: EndpointMockState, @@ -142,7 +142,7 @@ private fun ContentState( ) { var selectedTabIndex by remember { mutableIntStateOf(value = 0) } - val pagerState = rememberPagerState(pageCount = { uiState.hosts.size }) + val pagerState = rememberPagerState(pageCount = { uiState.groups.size }) LaunchedEffect(key1 = selectedTabIndex) { pagerState.animateScrollToPage(page = selectedTabIndex) @@ -174,11 +174,11 @@ private fun ContentState( selectedTabIndex = selectedTabIndex, edgePadding = 0.dp ) { - uiState.hosts.forEachIndexed { index, host -> + uiState.groups.forEachIndexed { index, group -> Tab( selected = selectedTabIndex == index, onClick = { selectedTabIndex = index }, - text = { Text(text = host.name) } + text = { Text(text = group.name) } ) } } @@ -188,48 +188,45 @@ private fun ContentState( .fillMaxWidth(), verticalAlignment = Alignment.Top ) { pageIndex -> - val host = uiState.hosts.getOrNull(index = pageIndex) ?: return@HorizontalPager + val group = uiState.groups.getOrNull(index = pageIndex) ?: return@HorizontalPager LazyColumn( modifier = Modifier .weight(weight = 1f), verticalArrangement = Arrangement.spacedBy(space = 0.dp) ) { itemsIndexed( - items = host.endpoints, - key = { _, endpoint -> "${host.id}-${endpoint.descriptor.endpointId}" } + items = group.endpoints, + key = { _, endpoint -> endpoint.descriptor.key.compositeKey } ) { index, endpoint -> EndpointCard( endpoint = endpoint, - openEndpointBottomSheet = { - selectEndpoint( - endpoint.descriptor.hostId, - endpoint.descriptor.endpointId - ) + openEndpointDetails = { + openEndpointDetails(endpoint.descriptor.key) }, showFileName = true ) - if (index != host.endpoints.lastIndex) { + if (index != group.endpoints.lastIndex) { HorizontalDivider() } } + + item { + Spacer(modifier = Modifier.padding(bottom = bottomPadding)) + } } } } - selectedDescriptor?.let { descriptor -> - NetworkMockEndpointBottomSheet( - descriptor = descriptor, - currentState = selectedEndpointState, - onDismissRequest = { clearSelectedEndpoint() }, - onSelectResponse = { fileName -> - setEndpointMockState( - descriptor.hostId, - descriptor.endpointId, - fileName - ) - } - ) - } +// selectedDescriptor?.let { descriptor -> +// NetworkMockEndpointScreen( +// descriptor = descriptor, +// currentState = selectedEndpointState, +// onDismissRequest = { clearSelectedEndpoint() }, +// onSelectResponse = { fileName -> +// setEndpointMockState(descriptor.key, fileName) +// } +// ) +// } } @Preview(locale = "en") @@ -242,8 +239,8 @@ private fun NetworkMockScreenPreview( NetworkMockScreenContent( uiState = uiState, onGlobalToggle = {}, - setEndpointMockState = { _, _, _ -> }, - selectEndpoint = { _, _ -> }, + setEndpointMockState = { _, _ -> }, + navigateToEndpointScreen = {}, clearSelectedEndpoint = {}, selectedDescriptor = null, selectedEndpointState = EndpointMockState.Network diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointCard.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointCard.kt index 83876e9..1c53f43 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointCard.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointCard.kt @@ -19,8 +19,8 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import com.worldline.devview.networkmock.model.EndpointUiModel import com.worldline.devview.networkmock.preview.EndpointUiModelPreviewParameterProvider -import com.worldline.devview.networkmock.viewmodel.EndpointUiModel /** * Card component for displaying and configuring a single API endpoint mock. @@ -29,13 +29,13 @@ import com.worldline.devview.networkmock.viewmodel.EndpointUiModel * and a dropdown to select which mock response to return. * * @param endpoint The endpoint UI model pairing static config with live state - * @param openEndpointBottomSheet Callback invoked when the card is tapped + * @param openEndpointDetails Callback invoked when the card is tapped * @param modifier Optional modifier */ @Composable public fun EndpointCard( endpoint: EndpointUiModel, - openEndpointBottomSheet: () -> Unit, + openEndpointDetails: () -> Unit, modifier: Modifier = Modifier, showFileName: Boolean = false ) { @@ -47,7 +47,7 @@ public fun EndpointCard( .fillMaxWidth() .clickable( enabled = true, - onClick = openEndpointBottomSheet + onClick = openEndpointDetails ).padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween @@ -233,7 +233,7 @@ private fun EndpointCardPreview( Surface { EndpointCard( endpoint = endpoint, - openEndpointBottomSheet = {} + openEndpointDetails = {} ) } } @@ -250,7 +250,7 @@ private fun EndpointCardWithFileNamePreview( Surface { EndpointCard( endpoint = endpoint, - openEndpointBottomSheet = {}, + openEndpointDetails = {}, showFileName = true ) } diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointHeaderCard.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointHeaderCard.kt new file mode 100644 index 0000000..2c0cb2d --- /dev/null +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointHeaderCard.kt @@ -0,0 +1,87 @@ +package com.worldline.devview.networkmock.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.worldline.devview.networkmock.model.EndpointUiModel +import com.worldline.devview.networkmock.preview.EndpointUiModelPreviewParameterProvider + +@Composable +internal fun EndpointHeaderCard(endpoint: EndpointUiModel, modifier: Modifier = Modifier) { + ElevatedCard( + modifier = modifier + .fillMaxWidth(), + elevation = CardDefaults.elevatedCardElevation(), + shape = MaterialTheme.shapes.small + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + modifier = Modifier + .weight(weight = 1f) + ) { + Text( + text = endpoint.descriptor.config.name, + style = MaterialTheme.typography.titleMedium + ) + Row( + horizontalArrangement = Arrangement.spacedBy(space = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = endpoint.descriptor.config.method, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontFamily = FontFamily.Monospace + ) + Text( + text = endpoint.descriptor.config.path, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace + ) + } + } + EndpointStateChip( + endpointMockState = endpoint.currentState + ) + } + } +} + +@Preview(locale = "en") +@Composable +internal fun EndpointHeaderCardPreview( + @PreviewParameter( + EndpointUiModelPreviewParameterProvider::class + ) endpoint: EndpointUiModel +) { + MaterialTheme { + Surface { + EndpointHeaderCard( + endpoint = endpoint, + modifier = Modifier + .fillMaxWidth() + ) + } + } +} diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointStateChip.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointStateChip.kt index ffc174c..c0986a3 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointStateChip.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/EndpointStateChip.kt @@ -17,14 +17,21 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import com.worldline.devview.networkmock.model.EndpointMockState +import com.worldline.devview.networkmock.model.EndpointUiModel import com.worldline.devview.networkmock.preview.EndpointUiModelPreviewParameterProvider import com.worldline.devview.networkmock.utils.containerColor import com.worldline.devview.networkmock.utils.contentColor import com.worldline.devview.networkmock.utils.icon -import com.worldline.devview.networkmock.viewmodel.EndpointUiModel @Composable -public fun EndpointStateChip(endpointMockState: EndpointMockState, modifier: Modifier = Modifier) { +public fun EndpointStateChip( + endpointMockState: EndpointMockState, + modifier: Modifier = Modifier, + label: String = when (endpointMockState) { + is EndpointMockState.Mock -> endpointMockState.statusCode.toString() + EndpointMockState.Network -> endpointMockState.displayName + } +) { Row( modifier = modifier .clip( @@ -46,10 +53,7 @@ public fun EndpointStateChip(endpointMockState: EndpointMockState, modifier: Mod tint = endpointMockState.contentColor ) Text( - text = when (endpointMockState) { - is EndpointMockState.Mock -> endpointMockState.statusCode.toString() - EndpointMockState.Network -> endpointMockState.displayName - }, + text = label, style = MaterialTheme.typography.bodySmallEmphasized, color = endpointMockState.contentColor ) diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockItem.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockItem.kt index 62db0ca..c68912e 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockItem.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockItem.kt @@ -1,22 +1,31 @@ package com.worldline.devview.networkmock.components import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.CheckBox import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -30,11 +39,15 @@ import com.worldline.devview.networkmock.utils.contentColorForStatusCode import com.worldline.devview.networkmock.utils.fake import com.worldline.devview.networkmock.utils.icon import com.worldline.devview.networkmock.utils.iconForStatusCode +import com.worldline.devview.utils.preview.BooleanPreviewParameterProvider +import kotlin.math.abs @Composable internal fun MockItem( mockResponse: MockResponse, onClick: () -> Unit, + onLongClick: () -> Unit, + isInPreviewMode: Boolean, modifier: Modifier = Modifier, selected: Boolean = false ) { @@ -43,7 +56,9 @@ internal fun MockItem( statusCode = mockResponse.statusCode, label = mockResponse.fileName, selected = selected, - onClick = onClick + onClick = onClick, + onLongClick = onLongClick, + isInPreviewMode = isInPreviewMode ) } @@ -59,7 +74,9 @@ internal fun NetworkItem( label = EndpointMockState.Network.displayName, selected = selected, isNetwork = true, - onClick = onClick + onClick = onClick, + onLongClick = null, + isInPreviewMode = false ) } @@ -69,6 +86,8 @@ private fun MockItemContent( label: String, selected: Boolean, onClick: () -> Unit, + onLongClick: (() -> Unit)?, + isInPreviewMode: Boolean, modifier: Modifier = Modifier, isNetwork: Boolean = false ) { @@ -97,22 +116,46 @@ private fun MockItemContent( Row( modifier = Modifier .fillMaxWidth() - .clickable( - enabled = !selected, - onClick = onClick + .combinedClickable( + enabled = true, + onClick = { + if (!selected) { + onClick() + } + }, + onLongClick = onLongClick ).then( other = modifier + .minimumInteractiveComponentSize() ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(space = 16.dp) ) { + // Vertical-axis flip: animate from -1f (status icon side) to 1f (checkbox side). + // abs(flipProgress) drives scaleX so the icon collapses to 0 at the midpoint then + // expands back; the sign determines which icon is visible. + val flipTransition = updateTransition( + targetState = isInPreviewMode, + label = "leadingIconFlip" + ) + + val flipProgress by flipTransition.animateFloat( + transitionSpec = { tween(durationMillis = 300) }, + label = "flipProgress" + ) { inPreview -> if (inPreview) 1f else -1f } + + val displayedIcon by remember(key1 = icon) { + derivedStateOf { if (flipProgress >= 0f) Icons.Rounded.CheckBox else icon } + } + Icon( modifier = Modifier .padding(vertical = 4.dp) .clip(shape = MaterialTheme.shapes.small) .background(color = containerColor) - .padding(all = 4.dp), - imageVector = icon, + .padding(all = 4.dp) + .graphicsLayer { scaleX = abs(x = flipProgress) }, + imageVector = displayedIcon, contentDescription = null, tint = contentColor ) @@ -142,7 +185,9 @@ internal fun MockItemPreview( Surface { MockItem( mockResponse = mockResponse, - onClick = {} + onClick = {}, + onLongClick = {}, + isInPreviewMode = false ) } } @@ -150,11 +195,13 @@ internal fun MockItemPreview( @Preview(locale = "en") @Composable -internal fun NetworkItemPreview() { +internal fun NetworkItemPreview( + @PreviewParameter(BooleanPreviewParameterProvider::class) selected: Boolean +) { MaterialTheme { Surface { NetworkItem( - selected = true, + selected = selected, onClick = {} ) } @@ -163,13 +210,35 @@ internal fun NetworkItemPreview() { @Preview(locale = "en") @Composable -internal fun MockItemSelectedPreview() { +internal fun MockItemSelectedPreview( + @PreviewParameter(BooleanPreviewParameterProvider::class) selected: Boolean +) { MaterialTheme { Surface { MockItem( mockResponse = MockResponse.fake().first(), - selected = true, - onClick = {} + selected = selected, + onClick = {}, + onLongClick = {}, + isInPreviewMode = false + ) + } + } +} + +@Preview(locale = "en") +@Composable +internal fun MockItemPreviewModePreview( + @PreviewParameter(BooleanPreviewParameterProvider::class) isInPreviewMode: Boolean +) { + MaterialTheme { + Surface { + MockItem( + mockResponse = MockResponse.fake().first(), + selected = false, + onClick = {}, + onLongClick = {}, + isInPreviewMode = isInPreviewMode ) } } diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockResponseDiffColors.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockResponseDiffColors.kt new file mode 100644 index 0000000..a96ca7c --- /dev/null +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockResponseDiffColors.kt @@ -0,0 +1,171 @@ +package com.worldline.devview.networkmock.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color + +/** Light blue-white — background for left-side diff lines. Legible in both light and dark. */ +internal val DiffLeftContainer = Color(color = 0xFFD6E8FF) + +/** Muted steel blue — text colour on [DiffLeftContainer]. */ +internal val DiffOnLeftContainer = Color(color = 0xFF2D4A6E) + +/** Light bisque — background for right-side diff lines. Maximally distinct from [DiffLeftContainer]. */ +internal val DiffRightContainer = Color(color = 0xFFFFE4C4) + +/** Muted amber/brown — text colour on [DiffRightContainer]. */ +internal val DiffOnRightContainer = Color(color = 0xFF4A3320) + +/** + * Colours used by [InlineDiffContent], [SplitDiffContent], and [DiffLineRow] to render mock + * response diffs. + * + * Create an instance via [MockResponseDiffDefaults.colors], which provides Material3 defaults + * for every slot. Individual colours can be overridden for theming or branded customisation. + * + * @param surface Background colour for unchanged content rows. + * @param onSurface Text colour for unchanged content rows. + * @param gutterContainer Background colour of the line-number gutter columns. + * @param onGutterContainer Text colour used for line numbers and secondary labels in the gutter. + * @param collapsedContainer Background colour of the collapsed-lines placeholder row. + * @param leftContainer Background colour for lines that belong to the left (first) response only. + * @param onLeftContainer Text colour for lines highlighted with [leftContainer]. + * @param rightContainer Background colour for lines that belong to the right (second) response only. + * @param onRightContainer Text colour for lines highlighted with [rightContainer]. + */ +@Suppress("TooManyFunctions") +@Immutable +internal class MockResponseDiffColors( + internal val surface: Color, + internal val onSurface: Color, + internal val gutterContainer: Color, + internal val onGutterContainer: Color, + internal val collapsedContainer: Color, + internal val leftContainer: Color, + internal val onLeftContainer: Color, + internal val rightContainer: Color, + internal val onRightContainer: Color +) { + /** + * Returns a copy of these colours with the given overrides applied. + * All parameters default to the current value so only the slots you want to change + * need to be specified. + */ + fun copy( + surface: Color = this.surface, + onSurface: Color = this.onSurface, + gutterContainer: Color = this.gutterContainer, + onGutterContainer: Color = this.onGutterContainer, + collapsedContainer: Color = this.collapsedContainer, + leftContainer: Color = this.leftContainer, + onLeftContainer: Color = this.onLeftContainer, + rightContainer: Color = this.rightContainer, + onRightContainer: Color = this.onRightContainer + ): MockResponseDiffColors = MockResponseDiffColors( + surface = surface, + onSurface = onSurface, + gutterContainer = gutterContainer, + onGutterContainer = onGutterContainer, + collapsedContainer = collapsedContainer, + leftContainer = leftContainer, + onLeftContainer = onLeftContainer, + rightContainer = rightContainer, + onRightContainer = onRightContainer + ) + + @Stable + internal fun surface(): Color = surface + + @Stable + internal fun onSurface(): Color = onSurface + + @Stable + internal fun gutterContainer(): Color = gutterContainer + + @Stable + internal fun onGutterContainer(): Color = onGutterContainer + + @Stable + internal fun collapsedContainer(): Color = collapsedContainer + + @Stable + internal fun leftContainer(): Color = leftContainer + + @Stable + internal fun onLeftContainer(): Color = onLeftContainer + + @Stable + internal fun rightContainer(): Color = rightContainer + + @Stable + internal fun onRightContainer(): Color = onRightContainer + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MockResponseDiffColors) return false + if (surface != other.surface) return false + if (onSurface != other.onSurface) return false + if (gutterContainer != other.gutterContainer) return false + if (onGutterContainer != other.onGutterContainer) return false + if (collapsedContainer != other.collapsedContainer) return false + if (leftContainer != other.leftContainer) return false + if (onLeftContainer != other.onLeftContainer) return false + if (rightContainer != other.rightContainer) return false + if (onRightContainer != other.onRightContainer) return false + return true + } + + override fun hashCode(): Int { + var result = surface.hashCode() + result = 31 * result + onSurface.hashCode() + result = 31 * result + gutterContainer.hashCode() + result = 31 * result + onGutterContainer.hashCode() + result = 31 * result + collapsedContainer.hashCode() + result = 31 * result + leftContainer.hashCode() + result = 31 * result + onLeftContainer.hashCode() + result = 31 * result + rightContainer.hashCode() + result = 31 * result + onRightContainer.hashCode() + return result + } +} + +/** + * Default values for [MockResponseDiffColors]. + * + * Use [colors] to obtain a fully populated instance backed by the current Material3 theme. + */ +internal object MockResponseDiffDefaults { + /** + * Creates a [MockResponseDiffColors] populated with Material3 colour-scheme defaults. + * Override any slot to customise the appearance of the diff components. + * + * - [leftContainer] / [onLeftContainer] and [rightContainer] / [onRightContainer] are + * hardcoded to muted blue and amber/brown respectively. These are theme-agnostic values + * chosen to be clearly distinct from each other and legible in both light and dark modes, + * without implying additions or removals. + */ + @Composable + fun colors( + surface: Color = MaterialTheme.colorScheme.surface, + onSurface: Color = MaterialTheme.colorScheme.onSurface, + gutterContainer: Color = MaterialTheme.colorScheme.surfaceContainerHigh, + onGutterContainer: Color = MaterialTheme.colorScheme.onSurfaceVariant, + collapsedContainer: Color = MaterialTheme.colorScheme.surfaceContainerLow, + leftContainer: Color = DiffLeftContainer, + onLeftContainer: Color = DiffOnLeftContainer, + rightContainer: Color = DiffRightContainer, + onRightContainer: Color = DiffOnRightContainer + ): MockResponseDiffColors = MockResponseDiffColors( + surface = surface, + onSurface = onSurface, + gutterContainer = gutterContainer, + onGutterContainer = onGutterContainer, + collapsedContainer = collapsedContainer, + leftContainer = leftContainer, + onLeftContainer = onLeftContainer, + rightContainer = rightContainer, + onRightContainer = onRightContainer + ) +} diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockResponseDiffContent.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockResponseDiffContent.kt new file mode 100644 index 0000000..27bb440 --- /dev/null +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockResponseDiffContent.kt @@ -0,0 +1,362 @@ +package com.worldline.devview.networkmock.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.worldline.devview.networkmock.PreviewSheetState +import com.worldline.devview.networkmock.model.DiffLine +import com.worldline.devview.networkmock.model.DisplayLine +import com.worldline.devview.networkmock.model.MockResponse +import com.worldline.devview.networkmock.preview.PreviewSheetStatePreviewParameterProvider +import com.worldline.devview.networkmock.utils.CONTEXT_LINES +import com.worldline.devview.networkmock.utils.toDisplayLines +import kotlinx.collections.immutable.PersistentList + +/** + * Renders an inline unified diff for two mock response bodies that are structurally + * similar enough to warrant a unified view (as determined by [MockResponseDiffDefaults]). + * + * - Unchanged lines are rendered normally with a line-number gutter on both sides. + * - Differing lines use a [MockResponseDiffColors.leftContainer] background for the left response + * and [MockResponseDiffColors.rightContainer] for the right. These map to `primaryContainer` and + * `secondaryContainer` by default to avoid implying additions or removals. + * - Long runs of unchanged lines are collapsed into a "… N unchanged lines …" label, + * showing only [CONTEXT_LINES] lines of context on each side of a changed region. + * - When [leftLabel] and [rightLabel] are provided, a colour legend is rendered above the diff + * rows to identify which colour corresponds to which response. + * + * @param diff The pre-computed list of [DiffLine]s to render. + * @param leftLabel Optional label for the left (first) response shown in the colour legend. + * @param rightLabel Optional label for the right (second) response shown in the colour legend. + * @param colors Colours used to render the diff. Defaults to [MockResponseDiffDefaults.colors]. + * @param modifier [Modifier] to be applied to the root [Column]. + */ +@Composable +internal fun InlineDiffContent( + diff: PersistentList, + modifier: Modifier = Modifier, + leftLabel: String? = null, + rightLabel: String? = null, + colors: MockResponseDiffColors = MockResponseDiffDefaults.colors() +) { + val displayLines = remember(key1 = diff) { diff.toDisplayLines() } + + Column( + modifier = modifier.fillMaxWidth() + ) { + if (leftLabel != null && rightLabel != null) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(space = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + LegendDot( + color = colors.leftContainer(), + label = leftLabel, + colors = colors + ) + LegendDot( + color = colors.rightContainer(), + label = rightLabel, + colors = colors + ) + } + HorizontalDivider() + } + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(state = rememberScrollState()) + ) { + displayLines.forEach { line -> + when (line) { + is DisplayLine.Unchanged -> { + DiffLineRow( + lineLeft = line.lineLeft.toString(), + lineRight = line.lineRight.toString(), + content = line.text, + background = colors.surface(), + textColor = colors.onSurface(), + gutterColor = colors.gutterContainer(), + gutterTextColor = colors.onGutterContainer() + ) + } + + is DisplayLine.Left -> { + DiffLineRow( + lineLeft = line.line.toString(), + lineRight = "", + content = line.text, + background = colors.leftContainer(), + textColor = colors.onLeftContainer(), + gutterColor = colors.gutterContainer(), + gutterTextColor = colors.onGutterContainer() + ) + } + + is DisplayLine.Right -> { + DiffLineRow( + lineLeft = "", + lineRight = line.line.toString(), + content = line.text, + background = colors.rightContainer(), + textColor = colors.onRightContainer(), + gutterColor = colors.gutterContainer(), + gutterTextColor = colors.onGutterContainer() + ) + } + + is DisplayLine.Collapsed -> { + Text( + modifier = Modifier + .fillMaxWidth() + .background(color = colors.collapsedContainer()) + .padding(horizontal = 12.dp, vertical = 2.dp), + text = " ⋯ ${line.count} unchanged lines", + style = MaterialTheme.typography.labelSmall, + color = colors.onGutterContainer(), + fontFamily = FontFamily.Monospace + ) + } + } + } + } + } +} + +/** + * A small coloured dot with a text label, used in the [InlineDiffContent] colour legend. + * + * @param color The background colour of the dot. + * @param label The text label displayed next to the dot. + * @param colors The diff colours used to derive the label text colour. + * @param modifier [Modifier] to be applied to the root [Row]. + */ +@Composable +private fun LegendDot( + color: Color, + label: String, + colors: MockResponseDiffColors, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(space = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(size = 10.dp) + .clip(shape = MaterialTheme.shapes.extraSmall) + .background(color = color) + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = colors.onGutterContainer() + ) + } +} + +/** + * A single row in the inline diff: left gutter number | right gutter number | content. + */ +@Composable +internal fun DiffLineRow( + lineLeft: String, + lineRight: String, + content: String, + background: Color, + textColor: Color, + gutterColor: Color, + gutterTextColor: Color, + modifier: Modifier = Modifier, + showRightGutter: Boolean = true +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(intrinsicSize = IntrinsicSize.Min) + .background(color = background), + verticalAlignment = Alignment.CenterVertically + ) { + // Left line number + Text( + modifier = Modifier + .width(width = 32.dp) + .fillMaxHeight() + .background(color = gutterColor) + .padding(horizontal = 4.dp, vertical = 1.dp), + text = lineLeft, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = gutterTextColor, + textAlign = TextAlign.End + ) + VerticalDivider(modifier = Modifier.fillMaxHeight()) + // Right line number — hidden in single-response view + if (showRightGutter) { + Text( + modifier = Modifier + .width(width = 32.dp) + .fillMaxHeight() + .background(color = gutterColor) + .padding(horizontal = 4.dp, vertical = 1.dp), + text = lineRight, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = gutterTextColor, + textAlign = TextAlign.End + ) + VerticalDivider(modifier = Modifier.fillMaxHeight()) + } + // Content — horizontally scrollable so long lines don't wrap + Text( + modifier = Modifier + .weight(weight = 1f) + .horizontalScroll(state = rememberScrollState()) + .padding(horizontal = 8.dp, vertical = 1.dp), + text = content, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = textColor, + softWrap = false + ) + } +} + +/** + * Renders two mock response bodies in a vertically split view for cases where the + * responses are too dissimilar for an inline diff. + * + * Each half takes equal vertical space, has its own independent [verticalScroll] and + * [horizontalScroll], and is identified by a small chip header. A [HorizontalDivider] + * separates the two halves. This layout is safe for portrait phone screens — no + * side-by-side columns. + * + * When only one response is provided ([second] is `null`), only the top half is rendered, + * making this composable reusable for the single-response preview case. + */ +@Composable +internal fun SplitDiffContent( + first: MockResponse, + second: MockResponse?, + modifier: Modifier = Modifier, + colors: MockResponseDiffColors = MockResponseDiffDefaults.colors() +) { + Column(modifier = modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth()) { + ResponseContentPane(response = first, colors = colors) + } + + if (second != null) { + HorizontalDivider() + + Column(modifier = Modifier.fillMaxWidth()) { + ResponseContentPane(response = second, colors = colors) + } + } + } +} + +/** + * A single scrollable pane showing the content of one [MockResponse] with a small + * chip label header identifying the response by [MockResponse.displayName]. + */ +@Composable +private fun ResponseContentPane( + response: MockResponse, + modifier: Modifier = Modifier, + colors: MockResponseDiffColors = MockResponseDiffDefaults.colors() +) { + val lines = remember(key1 = response.content) { response.content.lines() } + + Column(modifier = modifier.fillMaxWidth()) { + Text( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + text = response.fileName, + style = MaterialTheme.typography.labelSmall, + color = colors.onGutterContainer() + ) + HorizontalDivider() + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(state = rememberScrollState()) + ) { + lines.forEachIndexed { index, line -> + val lineNumber = (index + 1).toString() + DiffLineRow( + lineLeft = lineNumber, + lineRight = lineNumber, + content = line, + background = colors.surface(), + textColor = colors.onSurface(), + gutterColor = colors.gutterContainer(), + gutterTextColor = colors.onGutterContainer(), + showRightGutter = false + ) + } + } + } +} + +@Preview(name = "MockResponseDiffContent", locale = "en") +@Composable +private fun MockResponseDiffContentPreview( + @PreviewParameter(PreviewSheetStatePreviewParameterProvider::class) + previewSheetState: PreviewSheetState.HasResponse +) { + MaterialTheme { + Surface { + when (previewSheetState) { + is PreviewSheetState.Single -> SplitDiffContent( + first = previewSheetState.response, + second = null + ) + + is PreviewSheetState.Compare -> if (previewSheetState.useInlineDiff) { + InlineDiffContent( + diff = previewSheetState.lineDiff, + leftLabel = previewSheetState.first.displayName, + rightLabel = previewSheetState.second.displayName + ) + } else { + SplitDiffContent( + first = previewSheetState.first, + second = previewSheetState.second + ) + } + } + } + } +} diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/model/DiffLine.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/model/DiffLine.kt new file mode 100644 index 0000000..a656728 --- /dev/null +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/model/DiffLine.kt @@ -0,0 +1,26 @@ +package com.worldline.devview.networkmock.model + +import androidx.compose.runtime.Immutable + +/** + * Represents a single line in a computed diff between two mock response bodies. + * + * - [Unchanged] — the line is identical in both responses; carries 1-based line numbers + * for each side + * - [Different] — the line differs; either side may be null when one response has more + * lines than the other. Line numbers are null on the side that has no content for that + * position. + */ +@Immutable +internal sealed interface DiffLine { + @Immutable + data class Unchanged(val text: String, val lineLeft: Int, val lineRight: Int) : DiffLine + + @Immutable + data class Different( + val textLeft: String?, + val lineLeft: Int?, + val textRight: String?, + val lineRight: Int? + ) : DiffLine +} diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/model/DisplayLine.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/model/DisplayLine.kt new file mode 100644 index 0000000..d284ab8 --- /dev/null +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/model/DisplayLine.kt @@ -0,0 +1,23 @@ +package com.worldline.devview.networkmock.model + +import androidx.compose.runtime.Immutable + +/** + * A flattened display entry used by [InlineDiffContent]. + * Long runs of [DiffLine.Unchanged] are collapsed into [DisplayLine.Collapsed]. + */ +@Immutable +internal sealed interface DisplayLine { + @Immutable + data class Unchanged(val text: String, val lineLeft: Int, val lineRight: Int) : DisplayLine + + @Immutable + data class Left(val text: String, val line: Int) : DisplayLine + + @Immutable + data class Right(val text: String, val line: Int) : DisplayLine + + /** Placeholder for a collapsed run of unchanged lines. */ + @Immutable + data class Collapsed(val count: Int) : DisplayLine +} diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/model/EndpointUiModel.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/model/EndpointUiModel.kt new file mode 100644 index 0000000..716a6bb --- /dev/null +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/model/EndpointUiModel.kt @@ -0,0 +1,19 @@ +package com.worldline.devview.networkmock.model + +import androidx.compose.runtime.Immutable + +/** + * UI model pairing a static [EndpointDescriptor] with its live [EndpointMockState]. + * + * @property descriptor The immutable endpoint configuration and available responses. + * @property currentState The current runtime mock state for this endpoint. + * @see EndpointDescriptor + * @see EndpointMockState + */ +@Immutable +public data class EndpointUiModel( + val descriptor: EndpointDescriptor, + val currentState: EndpointMockState +) { + public companion object +} diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/model/GroupEnvironmentUiModel.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/model/GroupEnvironmentUiModel.kt new file mode 100644 index 0000000..8041706 --- /dev/null +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/model/GroupEnvironmentUiModel.kt @@ -0,0 +1,27 @@ +package com.worldline.devview.networkmock.model + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.PersistentList + +/** + * UI model for a single API group + environment combination. + * + * Each tab in the Network Mock screen represents one [GroupEnvironmentUiModel], + * showing the resolved endpoints for that specific group and environment. + * + * @property groupId The [com.worldline.devview.networkmock.model.ApiGroupConfig] identifier + * @property environmentId The [com.worldline.devview.networkmock.model.EnvironmentConfig] identifier + * @property name Human-readable display name, e.g. `"My Backend — Staging"` + * @property url The base URL for this group in this environment + * @property endpoints The resolved endpoints with their current mock states + */ +@Immutable +public data class GroupEnvironmentUiModel( + val groupId: String, + val environmentId: String, + val name: String, + val url: String, + val endpoints: PersistentList +) { + public companion object +} diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/preview/EndpointUiModelPreviewParameterProvider.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/preview/EndpointUiModelPreviewParameterProvider.kt index a7362d3..a51345e 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/preview/EndpointUiModelPreviewParameterProvider.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/preview/EndpointUiModelPreviewParameterProvider.kt @@ -1,8 +1,8 @@ package com.worldline.devview.networkmock.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.worldline.devview.networkmock.model.EndpointUiModel import com.worldline.devview.networkmock.utils.fake -import com.worldline.devview.networkmock.viewmodel.EndpointUiModel internal class EndpointUiModelPreviewParameterProvider : PreviewParameterProvider { diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/preview/NetworkMockUiStatePreviewParameterProvider.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/preview/NetworkMockUiStatePreviewParameterProvider.kt index 98dab8a..fb0cd9b 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/preview/NetworkMockUiStatePreviewParameterProvider.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/preview/NetworkMockUiStatePreviewParameterProvider.kt @@ -1,8 +1,8 @@ package com.worldline.devview.networkmock.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.worldline.devview.networkmock.model.GroupEnvironmentUiModel import com.worldline.devview.networkmock.utils.fake -import com.worldline.devview.networkmock.viewmodel.HostUiModel import com.worldline.devview.networkmock.viewmodel.NetworkMockUiState import kotlinx.collections.immutable.toPersistentList @@ -15,11 +15,11 @@ internal class NetworkMockUiStatePreviewParameterProvider : NetworkMockUiState.Empty, NetworkMockUiState.Content( globalMockingEnabled = true, - hosts = HostUiModel.fake().toPersistentList() + groups = GroupEnvironmentUiModel.fake().toPersistentList() ), NetworkMockUiState.Content( globalMockingEnabled = false, - hosts = HostUiModel.fake().toPersistentList() + groups = GroupEnvironmentUiModel.fake().toPersistentList() ) ) diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/preview/PreviewSheetStatePreviewParameterProvider.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/preview/PreviewSheetStatePreviewParameterProvider.kt new file mode 100644 index 0000000..3d7ead8 --- /dev/null +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/preview/PreviewSheetStatePreviewParameterProvider.kt @@ -0,0 +1,84 @@ +package com.worldline.devview.networkmock.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.worldline.devview.networkmock.PreviewSheetState +import com.worldline.devview.networkmock.model.MockResponse +import com.worldline.devview.networkmock.utils.fake + +internal class PreviewSheetStatePreviewParameterProvider : + PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + PreviewSheetState.Single( + response = MockResponse.fake().first() + ), + PreviewSheetState.Compare( + first = MockResponse( + statusCode = 200, + fileName = "endpoint-200.json", + displayName = "Success (200)", + content = + """ + { + "id": 1, + "name": "Alice", + "email": "alice@example.com", + "role": "admin" + } + """.trimIndent() + ), + second = MockResponse( + statusCode = 200, + fileName = "endpoint-200.json", + displayName = "Success (200)", + content = + """ + { + "id": 1, + "name": "Bob", + "email": "bob@example.com", + "role": "admin" + } + """.trimIndent() + ) + ), + PreviewSheetState.Compare( + first = MockResponse( + statusCode = 200, + fileName = "endpoint-200.json", + displayName = "Success (200)", + content = + """ + { + "id": 1, + "name": "Alice", + "email": "alice@example.com", + "role": "admin" + } + """.trimIndent() + ), + second = MockResponse( + statusCode = 500, + fileName = "endpoint-500.json", + displayName = "Server Error (500)", + content = + """ + { + "error": "InternalServerError", + "message": "An unexpected error occurred.", + "trace": "com.example.SomeService.doThing(SomeService.kt:42)" + } + """.trimIndent() + ) + ) + ) + + override fun getDisplayName(index: Int): String? = when ( + val state = values.elementAt( + index = index + ) + ) { + is PreviewSheetState.Single -> state.response.displayName + is PreviewSheetState.Compare -> "${state.first.displayName} vs ${state.second.displayName}" + } +} diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/DiffLineUtils.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/DiffLineUtils.kt new file mode 100644 index 0000000..990d0ce --- /dev/null +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/DiffLineUtils.kt @@ -0,0 +1,217 @@ +package com.worldline.devview.networkmock.utils + +import com.worldline.devview.networkmock.model.DiffLine +import com.worldline.devview.networkmock.model.DisplayLine +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.toPersistentList + +/** + * Threshold for deciding whether to use an inline diff (single column) or a split view + * (two columns) when comparing two content strings. Expressed as the minimum ratio of + * lines shared between both sides to the total number of lines. For example, 0.4 means + * at least 40 % of lines must be identical for an inline diff to be used. + */ +internal const val INLINE_DIFF_THRESHOLD = 0.4f + +/** + * Number of unchanged lines shown immediately above and below each changed region. + * Lines beyond this window are collapsed to keep the diff focused on what actually changed. + */ +internal const val CONTEXT_LINES = 3 + +/** + * Minimum run of consecutive unchanged lines that triggers collapsing. Runs shorter than + * this are shown in full — collapsing them would cost more space than it saves. + */ +internal const val COLLAPSE_THRESHOLD = CONTEXT_LINES * 2 + 1 + +/** + * Returns `true` when the two content strings are similar enough to be displayed as an inline + * diff rather than a split view. Similarity is measured as the ratio of shared lines to the + * total number of lines; the result is `true` when that ratio meets or exceeds [threshold]. + * + * @param contentLeft The left-hand content string to compare. + * @param contentRight The right-hand content string to compare. + * @param threshold Minimum similarity ratio required to prefer an inline diff. Defaults to + * [INLINE_DIFF_THRESHOLD]. + */ +internal fun shouldUseInlineDiff( + contentLeft: String, + contentRight: String, + threshold: Float = INLINE_DIFF_THRESHOLD +): Boolean { + val linesA = contentLeft.lines() + val linesB = contentRight.lines() + val lcsLength = lcsLength(a = linesA, b = linesB) + val maxLines = maxOf(a = linesA.size, b = linesB.size) + return if (maxLines == 0) true else lcsLength.toFloat() / maxLines >= threshold +} + +/** + * Computes a line-level diff between [contentLeft] and [contentRight] and returns a list of + * [DiffLine] values ready for rendering. + * + * The algorithm works by finding the **Longest Common Subsequence (LCS)** — the longest list + * of lines that appear in both sides in the same order. Lines in the LCS are marked as + * [DiffLine.Unchanged]; everything else is [DiffLine.Different]. Each entry also carries + * 1-based line numbers for the gutter. + */ +internal fun computeLineDiff(contentLeft: String, contentRight: String): PersistentList { + val linesA = contentLeft.lines() + val linesB = contentRight.lines() + + val dp = lcsTable(a = linesA, b = linesB) + val result = mutableListOf() + var i = linesA.size + var j = linesB.size + + while (i > 0 || j > 0) { + when { + i > 0 && j > 0 && linesA[i - 1] == linesB[j - 1] -> { + result.add( + element = DiffLine.Unchanged( + text = linesA[i - 1], + lineLeft = i, + lineRight = j + ) + ) + i-- + j-- + } + + j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) -> { + result.add( + element = DiffLine.Different( + textLeft = null, + lineLeft = null, + textRight = linesB[j - 1], + lineRight = j + ) + ) + j-- + } + + else -> { + result.add( + element = DiffLine.Different( + textLeft = linesA[i - 1], + lineLeft = i, + textRight = null, + lineRight = null + ) + ) + i-- + } + } + } + + return result.reversed().toPersistentList() +} + +/** + * Returns the length of the Longest Common Subsequence (LCS) between [a] and [b]. + * + * The LCS is the longest list of lines that appears in both sequences in the same order, + * not necessarily contiguous. For example, the LCS of `["a","b","c"]` and `["a","c","d"]` + * is `["a","c"]`, so the length is 2. + */ +@Suppress("DocumentationOverPrivateFunction") +private fun lcsLength(a: List, b: List): Int = lcsTable( + a = a, + b = b +)[a.size][b.size] + +/** + * Builds a Dynamic Programming (DP) table to compute the LCS length for every prefix pair + * of [a] and [b]. + * + * Dynamic Programming here means we solve the problem bottom-up: `dp[i][j]` holds the LCS + * length for the first `i` lines of [a] and the first `j` lines of [b]. Each cell is filled + * in constant time by looking at the cell above, the cell to the left, and the diagonal — + * so the full table is computed in O(m × n) time and space. + */ +@Suppress("DocumentationOverPrivateFunction") +private fun lcsTable(a: List, b: List): Array { + val m = a.size + val n = b.size + val dp = Array(size = m + 1) { IntArray(size = n + 1) } + for (i in 1..m) { + for (j in 1..n) { + dp[i][j] = if (a[i - 1] == b[j - 1]) { + dp[i - 1][j - 1] + 1 + } else { + maxOf(a = dp[i - 1][j], b = dp[i][j - 1]) + } + } + } + return dp +} + +/** + * Converts a [DiffLine] list into a [DisplayLine] list ready for the UI, collapsing long runs + * of unchanged lines to keep the view focused on the differences. + * + * Runs of [COLLAPSE_THRESHOLD] or more consecutive unchanged lines are trimmed to show only + * [CONTEXT_LINES] lines on each end, with a [DisplayLine.Collapsed] placeholder in the middle + * indicating how many lines were hidden. + */ +internal fun List.toDisplayLines(): PersistentList { + val display = mutableListOf() + + // First pass: flatten DiffLine → DisplayLine without collapsing + val flat = mutableListOf() + forEach { line -> + when (line) { + is DiffLine.Unchanged -> flat.add( + element = DisplayLine.Unchanged( + text = line.text, + lineLeft = line.lineLeft, + lineRight = line.lineRight + ) + ) + + is DiffLine.Different -> { + if (line.textLeft != null && line.lineLeft != null) { + flat.add(element = DisplayLine.Left(text = line.textLeft, line = line.lineLeft)) + } + if (line.textRight != null && line.lineRight != null) { + flat.add( + element = DisplayLine.Right( + text = line.textRight, + line = line.lineRight + ) + ) + } + } + } + } + + // Second pass: find runs of Unchanged and collapse the middle + var idx = 0 + while (idx < flat.size) { + val entry = flat[idx] + if (entry !is DisplayLine.Unchanged) { + display.add(element = entry) + idx++ + continue + } + + // Find the full run of consecutive Unchanged entries + var runEnd = idx + while (runEnd + 1 < flat.size && flat[runEnd + 1] is DisplayLine.Unchanged) runEnd++ + val runSize = runEnd - idx + 1 + + if (runSize <= COLLAPSE_THRESHOLD) { + // Short run — show all + for (k in idx..runEnd) display.add(element = flat[k]) + } else { + // Long run — show first CONTEXT_LINES, collapse middle, show last CONTEXT_LINES + for (k in idx until idx + CONTEXT_LINES) display.add(element = flat[k]) + display.add(element = DisplayLine.Collapsed(count = runSize - CONTEXT_LINES * 2)) + for (k in runEnd - CONTEXT_LINES + 1..runEnd) display.add(element = flat[k]) + } + idx = runEnd + 1 + } + + return display.toPersistentList() +} diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/ModelUtils.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/ModelUtils.kt index 5406dcc..cb4e506 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/ModelUtils.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/utils/ModelUtils.kt @@ -1,3 +1,5 @@ +@file:Suppress("StringLiteralDuplication") + package com.worldline.devview.networkmock.utils import androidx.compose.material.icons.Icons @@ -10,70 +12,89 @@ import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Wifi import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.capitalize +import androidx.compose.ui.text.intl.Locale import com.worldline.devview.networkmock.model.EndpointConfig import com.worldline.devview.networkmock.model.EndpointDescriptor +import com.worldline.devview.networkmock.model.EndpointKey import com.worldline.devview.networkmock.model.EndpointMockState +import com.worldline.devview.networkmock.model.EndpointUiModel +import com.worldline.devview.networkmock.model.GroupEnvironmentUiModel import com.worldline.devview.networkmock.model.MockResponse -import com.worldline.devview.networkmock.viewmodel.EndpointUiModel -import com.worldline.devview.networkmock.viewmodel.HostUiModel import kotlinx.collections.immutable.toPersistentList -internal fun HostUiModel.Companion.fake(amount: Int = 5): List = - List(size = amount) { index -> - HostUiModel( - id = "host-$index", - name = "Host $index", - url = "https://api.host$index.com", - endpoints = EndpointUiModel.fake().toPersistentList() - ) +internal fun GroupEnvironmentUiModel.Companion.fake( + amount: Int = 4 +): List = List(size = amount) { index -> + val groupId = "group${if (index <= 2) "" else index / 3 % 3}" + val environmentId = when (index % 3) { + 0 -> "development" + 1 -> "staging" + 2 -> "production" + else -> "staging" } + GroupEnvironmentUiModel( + groupId = groupId, + environmentId = environmentId, + name = groupId.capitalize( + locale = Locale.current + ) + " - " + environmentId.capitalize(locale = Locale.current), + url = "https://$groupId.$environmentId.api.com", + endpoints = EndpointUiModel + .fake( + groupId = groupId, + environmentId = environmentId + ).toPersistentList() + ) +} internal fun EndpointDescriptor.Companion.fake( amount: Int = 7, availableResponsesAmount: Int = 3, - hostId: String = "host" + groupId: String = "group", + environmentId: String = "staging" ): List = List(size = amount) { index -> EndpointDescriptor( - hostId = hostId, - endpointId = "endpoint-$index", + key = EndpointKey( + groupId = groupId, + environmentId = environmentId, + endpointId = "endpoint-${index + 1}" + ), config = EndpointConfig( - id = "endpoint-$index", - name = "Endpoint $index", + id = "endpoint-${index + 1}", + name = "Endpoint ${index + 1}", method = "GET", - path = "/endpoint$index" + path = "/endpoint${index + 1}" ), - availableResponses = MockResponse.fake( - amount = availableResponsesAmount - ) + availableResponses = MockResponse.fake(amount = availableResponsesAmount) ) } internal fun EndpointUiModel.Companion.fake( amount: Int = 7, availableResponsesAmount: Int = 3, - hostId: String = "host" + groupId: String = "group", + environmentId: String = "staging" ): List = EndpointDescriptor .fake( amount = amount, availableResponsesAmount = availableResponsesAmount, - hostId = hostId + groupId = groupId, + environmentId = environmentId ).mapIndexed { index, descriptor -> EndpointUiModel( descriptor = descriptor, - currentState = when (index % 6) { - in 0..5 -> EndpointMockState.Mock(responseFile = "response-${index + 1}00.json") + currentState = when (index) { + in 0..5 -> EndpointMockState.Mock(responseFile = responseFile(index = index)) else -> EndpointMockState.Network } ) } internal val EndpointMockState.icon: ImageVector - get() { - return when (this) { - is EndpointMockState.Mock -> iconForStatusCode(statusCode = statusCode) - - EndpointMockState.Network -> Icons.Rounded.Wifi - } + get() = when (this) { + is EndpointMockState.Mock -> iconForStatusCode(statusCode = statusCode) + EndpointMockState.Network -> Icons.Rounded.Wifi } internal fun iconForStatusCode(statusCode: Int?): ImageVector = when (statusCode) { @@ -86,12 +107,9 @@ internal fun iconForStatusCode(statusCode: Int?): ImageVector = when (statusCode } internal val EndpointMockState.contentColor: Color - get() { - return when (this) { - is EndpointMockState.Mock -> contentColorForStatusCode(statusCode = statusCode) - - EndpointMockState.Network -> Color(color = 0xFF0D1F3A) - } + get() = when (this) { + is EndpointMockState.Mock -> contentColorForStatusCode(statusCode = statusCode) + EndpointMockState.Network -> Color(color = 0xFF0D1F3A) } internal fun contentColorForStatusCode(statusCode: Int?): Color = when (statusCode) { @@ -104,12 +122,9 @@ internal fun contentColorForStatusCode(statusCode: Int?): Color = when (statusCo } internal val EndpointMockState.containerColor: Color - get() { - return when (this) { - is EndpointMockState.Mock -> containerColorForStatusCode(statusCode = statusCode) - - EndpointMockState.Network -> Color(color = 0xFFABC4ED) - } + get() = when (this) { + is EndpointMockState.Mock -> containerColorForStatusCode(statusCode = statusCode) + EndpointMockState.Network -> Color(color = 0xFFABC4ED) } internal fun containerColorForStatusCode(statusCode: Int?): Color = when (statusCode) { @@ -124,9 +139,11 @@ internal fun containerColorForStatusCode(statusCode: Int?): Color = when (status internal fun MockResponse.Companion.fake(amount: Int = 3): List = List(size = amount) { index -> MockResponse( - fileName = "response$index.json", + fileName = responseFile(index = index), statusCode = (index + 1) % 6 * 100, displayName = "Response $index", content = "{\n \"message\": \"This is a mock response $index\"\n}" ) } + +private fun responseFile(index: Int) = "response-${(index + 1) % 6 * 100 + index}.json" diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockEndpointViewModel.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockEndpointViewModel.kt new file mode 100644 index 0000000..fa18fc1 --- /dev/null +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/viewmodel/NetworkMockEndpointViewModel.kt @@ -0,0 +1,199 @@ +package com.worldline.devview.networkmock.viewmodel + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.worldline.devview.networkmock.model.EndpointDescriptor +import com.worldline.devview.networkmock.model.EndpointKey +import com.worldline.devview.networkmock.model.EndpointMockState +import com.worldline.devview.networkmock.model.EndpointUiModel +import com.worldline.devview.networkmock.model.effectiveEndpoints +import com.worldline.devview.networkmock.repository.MockConfigRepository +import com.worldline.devview.networkmock.repository.MockStateRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +private const val WHILE_SUBSCRIBED_TIMEOUT_MS = 5000L + +/** + * ViewModel for the Network Mock endpoint detail screen. + * + * Manages the state and business logic for a single endpoint, combining its + * discovered mock responses with the live persisted [EndpointMockState] so the + * UI always reflects the latest selection without any manual lookup. + * + * ## Responsibilities + * - Discover available mock response files for the given [endpointKey] + * - Observe the live [EndpointMockState] for the endpoint from DataStore + * - Combine both into a single [uiState] flow + * - Handle mock state changes triggered by the user + * + * @property endpointKey The [EndpointKey] identifying the endpoint this screen represents + * @property configRepository Repository used to discover available mock response files + * @property stateRepository Repository used to observe and persist mock state + */ +public class NetworkMockEndpointViewModel( + private val endpointKey: EndpointKey, + private val configRepository: MockConfigRepository, + private val stateRepository: MockStateRepository +) : ViewModel() { + private val privateDescriptor = MutableStateFlow(value = null) + private val privateLoadingState = + MutableStateFlow(value = EndpointLoadingState.Loading) + + /** + * Combined UI state for the endpoint detail screen. + * + * Combines the discovered [EndpointDescriptor] (loaded once on init) with the live + * [EndpointMockState] from DataStore into a single [NetworkMockEndpointUiState] + * emission. Re-emits whenever either source changes — in practice, [EndpointMockState] + * changes on every user selection while [EndpointDescriptor] is stable after loading. + * + * @see NetworkMockEndpointUiState + */ + public val uiState: StateFlow = combine( + flow = privateDescriptor, + flow2 = stateRepository.observeState(), + flow3 = privateLoadingState + ) { descriptor, runtimeState, loadingState -> + when (loadingState) { + is EndpointLoadingState.Loading -> NetworkMockEndpointUiState.Loading + is EndpointLoadingState.Error -> NetworkMockEndpointUiState.Error( + message = loadingState.message + ) + is EndpointLoadingState.Loaded -> { + if (descriptor == null) { + NetworkMockEndpointUiState.Error(message = "Endpoint not found") + } else { + NetworkMockEndpointUiState.Content( + endpointUiModel = EndpointUiModel( + descriptor = descriptor, + currentState = runtimeState.getEndpointState(key = endpointKey) + ?: EndpointMockState.Network + ) + ) + } + } + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = WHILE_SUBSCRIBED_TIMEOUT_MS), + initialValue = NetworkMockEndpointUiState.Loading + ) + + init { + loadEndpoint() + } + + /** + * Discovers available mock response files for [endpointKey] and assembles the + * [EndpointDescriptor]. Called once on init. + */ + @Suppress("DocumentationOverPrivateFunction") + private fun loadEndpoint() { + viewModelScope.launch { + privateLoadingState.value = EndpointLoadingState.Loading + runCatching { + configRepository.discoverResponseFiles(key = endpointKey) + }.onSuccess { responses -> + privateDescriptor.value = EndpointDescriptor( + key = endpointKey, + config = configRepository + .loadConfiguration() + .getOrNull() + ?.apiGroups + ?.flatMap { group -> + group.environments.flatMap { env -> + group.effectiveEndpoints(environment = env).map { + it to + EndpointKey( + groupId = group.id, + environmentId = env.id, + endpointId = it.id + ) + } + } + }?.firstOrNull { (_, key) -> key == endpointKey } + ?.first + ?: run { + privateLoadingState.value = EndpointLoadingState.Error( + message = "Endpoint configuration not found" + ) + return@onSuccess + }, + availableResponses = responses + ) + privateLoadingState.value = EndpointLoadingState.Loaded + }.onFailure { error -> + privateLoadingState.value = EndpointLoadingState.Error( + message = error.message ?: "Failed to load endpoint" + ) + } + } + } + + /** + * Sets the mock state for this endpoint. + * + * Passing `null` reverts the endpoint to [EndpointMockState.Network], effectively + * disabling mocking for it. Passing a non-null [responseFileName] transitions it + * to [EndpointMockState.Mock] with the given file. + * + * @param responseFileName The response file name to activate, or `null` to use the + * actual network + */ + public fun setMockState(responseFileName: String?) { + viewModelScope.launch { + val newState = if (responseFileName != null) { + EndpointMockState.Mock(responseFile = responseFileName) + } else { + EndpointMockState.Network + } + stateRepository.setEndpointMockState(key = endpointKey, state = newState) + } + } +} + +/** + * UI state for the endpoint detail screen. + * + * Emitted by [NetworkMockEndpointViewModel.uiState]. + */ +@Immutable +public sealed interface NetworkMockEndpointUiState { + /** Response file discovery is in progress. */ + @Immutable + public data object Loading : NetworkMockEndpointUiState + + /** + * Discovery failed or the endpoint configuration could not be found. + * + * @property message Human-readable description of the failure + */ + @Immutable + public data class Error(val message: String) : NetworkMockEndpointUiState + + /** + * Endpoint loaded successfully. + * + * @property endpointUiModel The UI model combining the static [EndpointDescriptor] with the live + * [EndpointMockState] for the endpoint, reflecting the latest persisted selection and available mock responses. + */ + @Immutable + public data class Content(val endpointUiModel: EndpointUiModel) : NetworkMockEndpointUiState +} + +/** + * Internal loading state for [NetworkMockEndpointViewModel]. + */ +private sealed interface EndpointLoadingState { + data object Loading : EndpointLoadingState + + data object Loaded : EndpointLoadingState + + data class Error(val message: String) : EndpointLoadingState +} 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 1d7fa77..f4c95f3 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 @@ -4,8 +4,12 @@ import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.worldline.devview.networkmock.model.EndpointDescriptor +import com.worldline.devview.networkmock.model.EndpointKey import com.worldline.devview.networkmock.model.EndpointMockState +import com.worldline.devview.networkmock.model.EndpointUiModel +import com.worldline.devview.networkmock.model.GroupEnvironmentUiModel import com.worldline.devview.networkmock.model.MockConfiguration +import com.worldline.devview.networkmock.model.effectiveEndpoints import com.worldline.devview.networkmock.repository.MockConfigRepository import com.worldline.devview.networkmock.repository.MockStateRepository import kotlinx.collections.immutable.PersistentList @@ -48,16 +52,31 @@ public class NetworkMockViewModel( ) : ViewModel() { private val privateConfiguration = MutableStateFlow(value = null) private val privateLoadingState = MutableStateFlow(value = LoadingState.Loading) - private val privateEndpointMocks = MutableStateFlow>( + private val privateEndpointMocks = MutableStateFlow>( value = emptyMap() ) - private val selectedEndpointId = MutableStateFlow?>(value = null) + + /** + * The [EndpointKey] of the currently selected endpoint, or `null` when nothing is selected. + * Drives bottom sheet visibility — non-null means open. + */ + @Suppress("DocumentationOverPrivateProperty") + private val selectedEndpointKey = MutableStateFlow(value = null) /** * Combined UI state for the Network Mock screen. * - * This flow combines configuration, runtime state, and discovered responses - * to provide everything the UI needs to render. + * Combines [MockConfiguration] (loaded once from `mocks.json`), the live + * [com.worldline.devview.networkmock.model.NetworkMockState] from DataStore, the internal + * loading state, and the discovered [EndpointDescriptor] map into a single + * [NetworkMockUiState] emission. Re-emits whenever any of the four sources change. + * + * Each API group + environment pair in the configuration becomes one + * [GroupEnvironmentUiModel] tab. Within each tab, only endpoints whose + * [EndpointDescriptor] has already been discovered are included. + * + * @see NetworkMockUiState + * @see GroupEnvironmentUiModel */ public val uiState: StateFlow = combine( flow = privateConfiguration, @@ -74,26 +93,35 @@ public class NetworkMockViewModel( } else { NetworkMockUiState.Content( globalMockingEnabled = runtimeState.globalMockingEnabled, - hosts = config.hosts - .map { host -> - HostUiModel( - id = host.id, - name = host.id, - url = host.url, - endpoints = host.endpoints - .mapNotNull { endpoint -> - val key = "${host.id}-${endpoint.id}" - endpointMocks[key]?.let { descriptor -> - EndpointUiModel( - descriptor = descriptor, - currentState = runtimeState.getEndpointState( - hostId = host.id, - endpointId = endpoint.id - ) ?: EndpointMockState.Network + groups = config.apiGroups + .flatMap { group -> + group.environments.map { environment -> + val effectiveEndpoints = group.effectiveEndpoints( + environment = environment + ) + GroupEnvironmentUiModel( + groupId = group.id, + environmentId = environment.id, + name = "${group.name} — ${environment.name}", + url = environment.url, + endpoints = effectiveEndpoints + .mapNotNull { endpoint -> + val key = EndpointKey( + groupId = group.id, + environmentId = environment.id, + endpointId = endpoint.id ) - } - }.toPersistentList() - ) + endpointMocks[key]?.let { descriptor -> + EndpointUiModel( + descriptor = descriptor, + currentState = runtimeState + .getEndpointState(key = key) + ?: EndpointMockState.Network + ) + } + }.toPersistentList() + ) + } }.toPersistentList() ) } @@ -110,10 +138,10 @@ public class NetworkMockViewModel( * endpoint is selected. Drives bottom sheet visibility — non-null means open. */ public val selectedEndpointDescriptor: StateFlow = combine( - flow = selectedEndpointId, + flow = selectedEndpointKey, flow2 = privateEndpointMocks - ) { id, endpointMocks -> - id?.let { (hostId, endpointId) -> endpointMocks["$hostId-$endpointId"] } + ) { key, endpointMocks -> + key?.let { endpointMocks[it] } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(stopTimeoutMillis = WHILE_SUBSCRIBED_TIMEOUT_MS), @@ -126,12 +154,10 @@ public class NetworkMockViewModel( * bottom sheet highlight in sync without any UI-side lookup. */ public val selectedEndpointState: StateFlow = combine( - flow = selectedEndpointId, + flow = selectedEndpointKey, flow2 = stateRepository.observeState() - ) { id, runtimeState -> - id?.let { (hostId, endpointId) -> - runtimeState.getEndpointState(hostId = hostId, endpointId = endpointId) - } ?: EndpointMockState.Network + ) { key, runtimeState -> + key?.let { runtimeState.getEndpointState(key = it) } ?: EndpointMockState.Network }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(stopTimeoutMillis = WHILE_SUBSCRIBED_TIMEOUT_MS), @@ -143,7 +169,8 @@ public class NetworkMockViewModel( } /** - * Loads the mock configuration from resources and discovers response files. + * Loads the mock configuration from resources and discovers response files for every + * group + environment + endpoint combination. */ @Suppress("DocumentationOverPrivateFunction") private fun loadConfiguration() { @@ -153,29 +180,43 @@ public class NetworkMockViewModel( configRepository .loadConfiguration() .onSuccess { config -> - stateRepository.registerEndpoints( - endpoints = config.hosts.flatMap { host -> - host.endpoints.map { endpoint -> host.id to endpoint.id } + // Pre-register every EndpointKey so write-side helpers have the full set + val allKeys = config.apiGroups.flatMap { group -> + group.environments.flatMap { environment -> + group.effectiveEndpoints(environment = environment).map { endpoint -> + EndpointKey( + groupId = group.id, + environmentId = environment.id, + endpointId = endpoint.id + ) + } } - ) + } + stateRepository.registerEndpoints(endpoints = allKeys) privateConfiguration.value = config - // Discover response files for all endpoints - val mocks = mutableMapOf() - config.hosts.forEach { host -> - host.endpoints.forEach { endpoint -> - val key = "${host.id}-${endpoint.id}" - val responses = configRepository.discoverResponseFiles( - endpointId = endpoint.id - ) - - mocks[key] = EndpointDescriptor( - hostId = host.id, - endpointId = endpoint.id, - config = endpoint, - availableResponses = responses - ) + // Discover response files for every group + environment + endpoint + val mocks = mutableMapOf() + config.apiGroups.forEach { group -> + group.environments.forEach { environment -> + group + .effectiveEndpoints(environment = environment) + .forEach { endpoint -> + val key = EndpointKey( + groupId = group.id, + environmentId = environment.id, + endpointId = endpoint.id + ) + val responses = configRepository.discoverResponseFiles( + key = key + ) + mocks[key] = EndpointDescriptor( + key = key, + config = endpoint, + availableResponses = responses + ) + } } } @@ -191,6 +232,12 @@ public class NetworkMockViewModel( /** * Toggles global mocking on/off. + * + * When disabled, every HTTP request passes through to the actual network + * regardless of individual endpoint configurations. Persisted immediately + * to DataStore so the setting survives app restarts. + * + * @param enabled `true` to enable global mocking, `false` to disable */ public fun setGlobalMockingEnabled(enabled: Boolean) { viewModelScope.launch { @@ -199,7 +246,7 @@ public class NetworkMockViewModel( } /** - * Sets the mock state for a specific endpoint. + * Sets the mock state for a specific endpoint identified by an [EndpointKey]. * * When [responseFileName] is `null`, the endpoint state is set to * [EndpointMockState.Network], effectively disabling mocking for that endpoint. @@ -207,24 +254,18 @@ public class NetworkMockViewModel( * When [responseFileName] is non-null, the endpoint transitions to * [EndpointMockState.Mock] with the given file, replacing any previous state. * - * @param hostId The ID of the host that owns the endpoint. - * @param endpointId The ID of the endpoint to update. + * @param key The [EndpointKey] identifying the group, environment, and endpoint * @param responseFileName The response file to use for mocking, or `null` to use - * the actual network. + * the actual network */ - public fun setEndpointMockState(hostId: String, endpointId: String, responseFileName: String?) { + public fun setEndpointMockState(key: EndpointKey, responseFileName: String?) { viewModelScope.launch { val newState = if (responseFileName != null) { EndpointMockState.Mock(responseFile = responseFileName) } else { EndpointMockState.Network } - - stateRepository.setEndpointMockState( - hostId = hostId, - endpointId = endpointId, - state = newState - ) + stateRepository.setEndpointMockState(key = key, state = newState) } } @@ -245,11 +286,17 @@ public class NetworkMockViewModel( return@launch } - // Build a Network state for every configured endpoint - val allNetwork = config.hosts - .flatMap { host -> - host.endpoints.map { endpoint -> - "${host.id}-${endpoint.id}" to EndpointMockState.Network + // Build a Network state for every configured group + environment + endpoint + val allNetwork = config.apiGroups + .flatMap { group -> + group.environments.flatMap { environment -> + group.effectiveEndpoints(environment = environment).map { endpoint -> + EndpointKey( + groupId = group.id, + environmentId = environment.id, + endpointId = endpoint.id + ) to EndpointMockState.Network + } } }.toMap() @@ -263,86 +310,58 @@ public class NetworkMockViewModel( * Triggers reactive updates to [selectedEndpointDescriptor] and * [selectedEndpointState] so the UI requires no manual lookup. * - * @param hostId The ID of the host that owns the endpoint. - * @param endpointId The ID of the selected endpoint. + * @param key The [EndpointKey] identifying the group, environment, and endpoint */ - public fun selectEndpoint(hostId: String, endpointId: String) { - selectedEndpointId.value = hostId to endpointId + public fun selectEndpoint(key: EndpointKey) { + selectedEndpointKey.value = key } /** * Clears the current endpoint selection, closing the bottom sheet. */ public fun clearSelectedEndpoint() { - selectedEndpointId.value = null + selectedEndpointKey.value = null } } /** * UI state for the Network Mock screen. + * + * Emitted by [NetworkMockViewModel.uiState]. The UI renders different layouts + * depending on which variant is active. */ @Immutable public sealed interface NetworkMockUiState { - /** - * Loading configuration. - */ + /** Configuration is being loaded from resources. */ @Immutable public data object Loading : NetworkMockUiState /** - * Error loading configuration. + * Configuration failed to load. + * + * @property message Human-readable description of the failure */ @Immutable public data class Error(val message: String) : NetworkMockUiState - /** - * No mocks configured. - */ + /** Configuration loaded successfully but contains no API groups. */ @Immutable public data object Empty : NetworkMockUiState /** - * Content loaded successfully. + * Configuration loaded successfully and at least one group is available. + * + * @property globalMockingEnabled Whether the global mocking master switch is on + * @property groups One entry per API group + environment combination, each + * rendered as a tab in the UI */ @Immutable public data class Content( val globalMockingEnabled: Boolean, - val hosts: PersistentList + val groups: PersistentList ) : NetworkMockUiState } -/** - * UI model pairing a static [EndpointDescriptor] with its live [EndpointMockState]. - * - * Constructed fresh on every emission of [NetworkMockViewModel.uiState], ensuring - * that [currentState] always reflects the latest persisted value without any - * UI-side snapshot or lookup. - * - * @property descriptor The immutable endpoint configuration and available responses. - * @property currentState The current runtime mock state for this endpoint. - * @see EndpointDescriptor - * @see EndpointMockState - */ -@Immutable -public data class EndpointUiModel( - val descriptor: EndpointDescriptor, - val currentState: EndpointMockState -) { - public companion object -} - -/** - * UI model for a host with its endpoints. - */ -public data class HostUiModel( - val id: String, - val name: String, - val url: String, - val endpoints: PersistentList -) { - public companion object -} - /** * Internal loading state. */ diff --git a/devview-utils/build.gradle.kts b/devview-utils/build.gradle.kts index aa4608d..9b65355 100644 --- a/devview-utils/build.gradle.kts +++ b/devview-utils/build.gradle.kts @@ -9,7 +9,7 @@ plugins { kotlin { addDefaultDevViewTargets() - androidLibrary { + android { namespace = "com.worldline.devview.utils" } diff --git a/devview/build.gradle.kts b/devview/build.gradle.kts index bd1af8d..ea7ae53 100644 --- a/devview/build.gradle.kts +++ b/devview/build.gradle.kts @@ -9,7 +9,7 @@ plugins { kotlin { addDefaultDevViewTargets() - androidLibrary { + android { namespace = "com.worldline.devview" } diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/DevView.kt b/devview/src/commonMain/kotlin/com/worldline/devview/DevView.kt index b0d6273..a0c8a89 100644 --- a/devview/src/commonMain/kotlin/com/worldline/devview/DevView.kt +++ b/devview/src/commonMain/kotlin/com/worldline/devview/DevView.kt @@ -234,7 +234,7 @@ public fun DevView( val currentModule: Module? by remember(key1 = modules) { derivedStateOf { modules.find { module -> - module.destinations.containsKey(key = backstack.last()) + module.destinations.containsKey(key = backstack.last()::class) } } } @@ -249,7 +249,7 @@ public fun DevView( else -> currentModule ?.let { module -> - module.destinations[current]?.title ?: module.moduleName + module.destinations[current::class]?.title ?: module.moduleName }.orEmpty() } } @@ -260,7 +260,7 @@ public fun DevView( derivedStateOf { currentModule ?.destinations - ?.get(key = backstack.last()) + ?.get(key = backstack.last()::class) ?.actions .orEmpty() } @@ -352,10 +352,8 @@ public fun DevView( HomeScreen( modules = modules, openModule = { module -> - // Navigate to the module's first destination - module.destinations.keys.firstOrNull()?.let { first -> - backstack.add(element = first) - } + // Navigate to the module's entry destination + backstack.add(element = module.entryDestination) } ) } diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/core/DestinationMetadataExtensions.kt b/devview/src/commonMain/kotlin/com/worldline/devview/core/DestinationMetadataExtensions.kt index c83165b..cb02fca 100644 --- a/devview/src/commonMain/kotlin/com/worldline/devview/core/DestinationMetadataExtensions.kt +++ b/devview/src/commonMain/kotlin/com/worldline/devview/core/DestinationMetadataExtensions.kt @@ -1,6 +1,7 @@ package com.worldline.devview.core import androidx.navigation3.runtime.NavKey +import kotlin.reflect.KClass /** * Registers this [NavKey] as a destination with no title override and no actions. @@ -9,6 +10,9 @@ import androidx.navigation3.runtime.NavKey * [Module.moduleName] as the top app bar title, and no contextual actions * are required. * + * This overload is a convenience for `data object` destinations — it delegates to + * the [KClass] overload using `this::class`. + * * ## Example * ```kotlin * override val destinations = persistentMapOf( @@ -16,15 +20,45 @@ import androidx.navigation3.runtime.NavKey * ) * ``` * - * @receiver The [NavKey] representing the destination to register. - * @return A [Pair] of this [NavKey] and an empty [DestinationMetadata], suitable + * @receiver The [NavKey] instance representing the destination to register. + * @return A [Pair] of this destination's [KClass] and an empty [DestinationMetadata], + * suitable for use directly inside [kotlinx.collections.immutable.persistentMapOf]. + * + * @see KClass.asDestination + * @see withTitle + * @see withActions + * @see DestinationMetadata + */ +public fun NavKey.asDestination(): Pair, DestinationMetadata> = + this::class.asDestination() + +/** + * Registers this [KClass] as a destination with no title override and no actions. + * + * Prefer this overload for `data class` destinations, where no representative + * instance is available at module-construction time. The class itself is used as + * the map key so that metadata lookup works correctly for any instance of the + * destination, regardless of its runtime parameters. + * + * ## Example + * ```kotlin + * override val destinations = persistentMapOf( + * MyDestination.Main.asDestination(), // data object — instance overload + * MyDestination.Detail::class.asDestination() // data class — KClass overload + * ) + * ``` + * + * @receiver The [KClass] of the [NavKey] subtype representing the destination to register. + * @return A [Pair] of this [KClass] and an empty [DestinationMetadata], suitable * for use directly inside [kotlinx.collections.immutable.persistentMapOf]. * + * @see NavKey.asDestination * @see withTitle * @see withActions * @see DestinationMetadata */ -public fun NavKey.asDestination(): Pair = this to DestinationMetadata() +public fun KClass.asDestination(): Pair, DestinationMetadata> = + this to DestinationMetadata() /** * Registers this [NavKey] as a destination with a static title and no actions. @@ -32,6 +66,9 @@ public fun NavKey.asDestination(): Pair = this to D * Use this when you want a specific title shown in the top app bar for this * destination, but no contextual action buttons are needed. * + * This overload is a convenience for `data object` destinations — it delegates to + * the [KClass] overload using `this::class`. + * * ## Example * ```kotlin * override val destinations = persistentMapOf( @@ -46,18 +83,57 @@ public fun NavKey.asDestination(): Pair = this to D * } * ``` * - * @receiver The [NavKey] representing the destination to register. + * @receiver The [NavKey] instance representing the destination to register. + * @param title The title to display in the top app bar when this destination is active. + * @return A [Pair] of this destination's [KClass] and a [DestinationMetadata] with the + * given [title] and no actions, suitable for use directly inside + * [kotlinx.collections.immutable.persistentMapOf]. + * + * @see KClass.withTitle + * @see asDestination + * @see withActions + * @see DestinationMetadata + */ +public fun NavKey.withTitle(title: String): Pair, DestinationMetadata> = + this::class.withTitle(title = title) + +/** + * Registers this [KClass] as a destination with a static title and no actions. + * + * Prefer this overload for `data class` destinations, where no representative + * instance is available at module-construction time. The class itself is used as + * the map key so that the title is resolved correctly for any instance of the + * destination, regardless of its runtime parameters. + * + * ## Example + * ```kotlin + * override val destinations = persistentMapOf( + * MyDestination.Main.withTitle("Overview"), // data object — instance overload + * MyDestination.Detail::class.withTitle("Detail") // data class — KClass overload + * ) + * ``` + * + * To also add actions, use the overload that accepts a [DestinationMetadataBuilder] block: + * ```kotlin + * MyDestination.Detail::class.withTitle("Detail") { + * action(icon = Icons.Rounded.Refresh) { viewModel.refresh() } + * } + * ``` + * + * @receiver The [KClass] of the [NavKey] subtype representing the destination to register. * @param title The title to display in the top app bar when this destination is active. - * @return A [Pair] of this [NavKey] and a [DestinationMetadata] with the given [title] + * @return A [Pair] of this [KClass] and a [DestinationMetadata] with the given [title] * and no actions, suitable for use directly inside * [kotlinx.collections.immutable.persistentMapOf]. * + * @see NavKey.withTitle * @see asDestination * @see withActions * @see DestinationMetadata */ -public fun NavKey.withTitle(title: String): Pair = - this to DestinationMetadata(title = title) +public fun KClass.withTitle( + title: String +): Pair, DestinationMetadata> = this to DestinationMetadata(title = title) /** * Registers this [NavKey] as a destination with a static title and contextual actions @@ -66,6 +142,9 @@ public fun NavKey.withTitle(title: String): Pair = * Use this when you want both a specific top app bar title and one or more contextual * action buttons for this destination. * + * This overload is a convenience for `data object` destinations — it delegates to + * the [KClass] overload using `this::class`. + * * ## Example * ```kotlin * override val destinations = persistentMapOf( @@ -87,14 +166,15 @@ public fun NavKey.withTitle(title: String): Pair = * * To register a title without actions, use [withTitle] without a block. * - * @receiver The [NavKey] representing the destination to register. + * @receiver The [NavKey] instance representing the destination to register. * @param title The title to display in the top app bar when this destination is active. * @param block A [DestinationMetadataBuilder] DSL block in which you register actions * via [DestinationMetadataBuilder.action]. - * @return A [Pair] of this [NavKey] and a [DestinationMetadata] with the given [title] - * and the actions registered in [block], suitable for use directly inside + * @return A [Pair] of this destination's [KClass] and a [DestinationMetadata] with the + * given [title] and the actions registered in [block], suitable for use directly inside * [kotlinx.collections.immutable.persistentMapOf]. * + * @see KClass.withTitle * @see asDestination * @see withActions * @see DestinationMetadataBuilder @@ -103,7 +183,52 @@ public fun NavKey.withTitle(title: String): Pair = public fun NavKey.withTitle( title: String, block: DestinationMetadataBuilder.() -> Unit -): Pair = this to DestinationMetadata( +): Pair, DestinationMetadata> = this::class.withTitle( + title = title, + block = block +) + +/** + * Registers this [KClass] as a destination with a static title and contextual actions + * defined via a [DestinationMetadataBuilder] DSL block. + * + * Prefer this overload for `data class` destinations, where no representative + * instance is available at module-construction time. The class itself is used as + * the map key so that the title and actions are resolved correctly for any instance + * of the destination, regardless of its runtime parameters. + * + * ## Example + * ```kotlin + * override val destinations = persistentMapOf( + * MyDestination.Main.withTitle("Overview") { // data object — instance overload + * action(icon = Icons.Rounded.Refresh) { viewModel.refresh() } + * }, + * MyDestination.Detail::class.withTitle("Detail") { // data class — KClass overload + * action(icon = Icons.Rounded.Share) { viewModel.share() } + * } + * ) + * ``` + * + * To register a title without actions, use [withTitle] without a block. + * + * @receiver The [KClass] of the [NavKey] subtype representing the destination to register. + * @param title The title to display in the top app bar when this destination is active. + * @param block A [DestinationMetadataBuilder] DSL block in which you register actions + * via [DestinationMetadataBuilder.action]. + * @return A [Pair] of this [KClass] and a [DestinationMetadata] with the given [title] + * and the actions registered in [block], suitable for use directly inside + * [kotlinx.collections.immutable.persistentMapOf]. + * + * @see NavKey.withTitle + * @see asDestination + * @see withActions + * @see DestinationMetadataBuilder + * @see DestinationMetadata + */ +public fun KClass.withTitle( + title: String, + block: DestinationMetadataBuilder.() -> Unit +): Pair, DestinationMetadata> = this to DestinationMetadata( title = title, actions = DestinationMetadataBuilder().apply(block = block).build() ) @@ -116,6 +241,9 @@ public fun NavKey.withTitle( * [Module.moduleName], but you still want one or more contextual action buttons * for this destination. * + * This overload is a convenience for `data object` destinations — it delegates to + * the [KClass] overload using `this::class`. + * * ## Example * ```kotlin * override val destinations = persistentMapOf( @@ -127,13 +255,14 @@ public fun NavKey.withTitle( * * To also set an explicit title, use [withTitle] with a block instead. * - * @receiver The [NavKey] representing the destination to register. + * @receiver The [NavKey] instance representing the destination to register. * @param block A [DestinationMetadataBuilder] DSL block in which you register actions * via [DestinationMetadataBuilder.action]. - * @return A [Pair] of this [NavKey] and a [DestinationMetadata] with no title override - * and the actions registered in [block], suitable for use directly inside + * @return A [Pair] of this destination's [KClass] and a [DestinationMetadata] with no + * title override and the actions registered in [block], suitable for use directly inside * [kotlinx.collections.immutable.persistentMapOf]. * + * @see KClass.withActions * @see asDestination * @see withTitle * @see DestinationMetadataBuilder @@ -141,6 +270,46 @@ public fun NavKey.withTitle( */ public fun NavKey.withActions( block: DestinationMetadataBuilder.() -> Unit -): Pair = this to DestinationMetadata( +): Pair, DestinationMetadata> = this::class.withActions(block = block) + +/** + * Registers this [KClass] as a destination with no title override and contextual + * actions defined via a [DestinationMetadataBuilder] DSL block. + * + * Prefer this overload for `data class` destinations, where no representative + * instance is available at module-construction time. The class itself is used as + * the map key so that actions are resolved correctly for any instance of the + * destination, regardless of its runtime parameters. + * + * ## Example + * ```kotlin + * override val destinations = persistentMapOf( + * MyDestination.Main.withActions { // data object — instance overload + * action(icon = Icons.Rounded.Delete) { viewModel.clear() } + * }, + * MyDestination.Detail::class.withActions { // data class — KClass overload + * action(icon = Icons.Rounded.Share) { viewModel.share() } + * } + * ) + * ``` + * + * To also set an explicit title, use [withTitle] with a block instead. + * + * @receiver The [KClass] of the [NavKey] subtype representing the destination to register. + * @param block A [DestinationMetadataBuilder] DSL block in which you register actions + * via [DestinationMetadataBuilder.action]. + * @return A [Pair] of this [KClass] and a [DestinationMetadata] with no title override + * and the actions registered in [block], suitable for use directly inside + * [kotlinx.collections.immutable.persistentMapOf]. + * + * @see NavKey.withActions + * @see asDestination + * @see withTitle + * @see DestinationMetadataBuilder + * @see DestinationMetadata + */ +public fun KClass.withActions( + block: DestinationMetadataBuilder.() -> Unit +): Pair, DestinationMetadata> = this to DestinationMetadata( actions = DestinationMetadataBuilder().apply(block = block).build() ) diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/core/Module.kt b/devview/src/commonMain/kotlin/com/worldline/devview/core/Module.kt index e397ee0..01c5740 100644 --- a/devview/src/commonMain/kotlin/com/worldline/devview/core/Module.kt +++ b/devview/src/commonMain/kotlin/com/worldline/devview/core/Module.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.Dp import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey +import kotlin.reflect.KClass import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.persistentMapOf import kotlinx.serialization.modules.PolymorphicModuleBuilder @@ -117,6 +118,7 @@ import kotlinx.serialization.modules.PolymorphicModuleBuilder * @see com.worldline.devview.DevView * @see rememberModules */ +@Suppress("ComplexInterface") public interface Module { /** * The name displayed in the module list on the DevView home screen. @@ -292,13 +294,39 @@ public interface Module { * * ## Best Practices * - Always include at least one destination (typically a `Main` screen). - * - The first key is used as the initial navigation target when the module is opened. * - Use [asDestination] for screens that need no title override and no actions. + * - For `data class` destinations use the [KClass] extension overloads + * (e.g. `MyDestination.Detail::class.withTitle("Detail")`), since no + * representative instance is available at module-construction time. * + * @see entryDestination * @see DestinationMetadata * @see NavKey */ - public val destinations: PersistentMap + public val destinations: PersistentMap, DestinationMetadata> + + /** + * The [NavKey] instance pushed onto the backstack when the user opens this module + * from the DevView home screen. + * + * This is the concrete destination instance that is navigated to on module entry. + * It must correspond to one of the keys registered in [destinations] (i.e. + * `entryDestination::class` must be a key in the map). + * + * For modules whose root screen is a `data object`, this is simply that object: + * ```kotlin + * override val entryDestination: NavKey = MyDestination.Main + * ``` + * + * For modules whose root screen is a `data class` (rare — the entry screen typically + * does not require parameters), construct the appropriate instance here: + * ```kotlin + * override val entryDestination: NavKey = MyDestination.Root(defaultParam) + * ``` + * + * @see destinations + */ + public val entryDestination: NavKey /** * Registers all destination serializers for type-safe navigation. @@ -476,7 +504,10 @@ internal fun previewModule( ): Module = object : Module { override val section: Section = section override val moduleName: String = name - override val destinations: PersistentMap = persistentMapOf() + override val destinations: PersistentMap, DestinationMetadata> = persistentMapOf() + override val entryDestination: NavKey get() = error( + message = "previewModule has no destinations" + ) override val registerSerializers: PolymorphicModuleBuilder.() -> Unit = {} override fun EntryProviderScope.registerContent( diff --git a/internal/dokka/build.gradle.kts b/internal/dokka/build.gradle.kts index cd2e509..ab15512 100644 --- a/internal/dokka/build.gradle.kts +++ b/internal/dokka/build.gradle.kts @@ -6,7 +6,7 @@ plugins { kotlin { addDefaultDevViewTargets() - androidLibrary { + android { namespace = "com.worldline.devview.docs" } } diff --git a/sample/NETWORK_MOCK_TESTING_GUIDE.md b/sample/NETWORK_MOCK_TESTING_GUIDE.md deleted file mode 100644 index 57afec3..0000000 --- a/sample/NETWORK_MOCK_TESTING_GUIDE.md +++ /dev/null @@ -1,361 +0,0 @@ -# Network Mock - Complete Integration & Testing Guide - -## ✅ INTEGRATION COMPLETE! - -The Network Mock module is now **fully integrated and functional** in the sample Android app! - ---- - -## What Was Implemented - -### 1. **Build Configuration** ✅ - -**File**: `sample/shared/build.gradle.kts` -- Added `convention.datastore` plugin -- Already had `devview-networkmock` dependency - -### 2. **HttpClient with Network Mock Plugin** ✅ - -Created new files to support Network Mock in HttpClient: - -**Files Created**: -- `HttpClientWithMocking.kt` (common - expect) -- `HttpClientWithMocking.android.kt` (actual implementation) -- `HttpClientWithMocking.ios.kt` (actual implementation) -- `RememberHttpClient.kt` (Composable helper) - -**Updated**: -- `BaseHttpClientConfig.kt` - Added NetworkMock plugin installation -- `SampleApi.kt` - Accepts HttpClient parameter -- `App.kt` - Uses HttpClient with mocking support - -### 3. **Module Registration** ✅ - -**File**: `MainActivity.kt` -- NetworkMock module added to DevView - -### 4. **Test API Endpoint** ✅ - -- `getUser(userId)` method calls JSONPlaceholder API -- Test button in the app UI -- Matches test configuration files - ---- - -## How It Works - -### Architecture - -``` -App.kt (Composable) - └─► rememberHttpClientWithMocking() - ├─► Creates DataStore - └─► Creates HttpClient - └─► Installs NetworkMockPlugin - ├─► MockConfigRepository (loads mocks.json) - └─► MockStateRepository (reads/writes DataStore) - -When API Call is made: - └─► NetworkMockPlugin.intercept() - ├─► Checks global mocking enabled - ├─► Finds matching endpoint - ├─► Checks endpoint mock enabled - ├─► Loads selected response file - └─► Returns MOCK or calls actual network -``` - -### Data Flow - -1. **User configures in DevView UI**: - - Toggle global mocking ON - - Enable "Get User by ID" endpoint - - Select "getUser-200.json" - - State saved to DataStore - -2. **User clicks "Test Network Mock" button**: - - Calls `api.getUser(1)` - - HttpClient intercepts request - - Plugin checks DataStore state - - Plugin finds mock enabled - - Plugin loads `getUser-200.json` - - Returns mock response! - ---- - -## Testing Instructions - -### Step 1: Build and Run - -```bash -./gradlew :sample:androidApp:installDebug -``` - -### Step 2: Test Without Mocking (Baseline) - -1. Launch the app -2. Click **"Test Network Mock (Get User)"** -3. See the **real response** from JSONPlaceholder API -4. Response shows actual user data from the live API - -### Step 3: Enable Mocking - -1. Click **"Open DevView"** -2. Navigate to **"Network Mock"** (in LOGGING section) -3. You should see: - - Global Mocking toggle (OFF by default) - - 2 hosts: jsonplaceholder & staging - - 6 endpoints total - -4. **Enable Global Mocking**: - - Toggle the top switch to ON - - See description change to "Mock responses enabled" - -5. **Configure "Get User by ID" endpoint**: - - Find the "Get User by ID" card - - Method: `GET` - - Path: `/users/{userId}` - - Toggle "Use Mock" to ON - - Dropdown appears showing available responses - -6. **Select a mock response**: - - Click the dropdown - - Choose **"getUser-200.json"** (Success 200) - - Or try **"getUser-404.json"** (Not Found 404) - - Or try **"getUser-500.json"** (Server Error 500) - -### Step 4: Test With Mocking - -1. Go back to the app (press back button) -2. Click **"Test Network Mock (Get User)"** again -3. See the **MOCK response** from the selected file! -4. Response now shows mock data instead of live API - -### Step 5: Try Different Responses - -**Success Scenario (200)**: -- Select `getUser-200.json` -- Click button -- See full user object with all fields - -**Error Scenario (404)**: -- Select `getUser-404.json` -- Click button -- See error response with null fields + error object - -**Server Error (500)**: -- Select `getUser-500.json` -- Click button -- See server error response - -### Step 6: Disable Mocking - -1. Open DevView → Network Mock -2. Toggle endpoint mock OFF, or -3. Toggle global mocking OFF -4. Go back and click button -5. See **real API response** again - ---- - -## Verification Checklist - -### ✅ Configuration Loading -- [ ] DevView shows Network Mock screen -- [ ] 2 hosts visible (jsonplaceholder, staging) -- [ ] 6 endpoints visible -- [ ] Each endpoint shows method and path - -### ✅ UI Controls -- [ ] Global toggle works -- [ ] Per-endpoint toggles work -- [ ] Dropdown shows available responses -- [ ] Selecting response updates UI -- [ ] Reset all button works - -### ✅ State Persistence -- [ ] Enable mocking and close app -- [ ] Reopen app -- [ ] Open DevView → Network Mock -- [ ] Settings are still enabled -- [ ] Selected response still selected - -### ✅ Request Interception -- [ ] Enable mock with 200 response -- [ ] Click test button -- [ ] See mock data (not real API) -- [ ] Disable mock -- [ ] Click test button -- [ ] See real API data - -### ✅ Different Responses -- [ ] Select 200 response → See success data -- [ ] Select 404 response → See error data -- [ ] Select 500 response → See server error -- [ ] All responses have consistent structure - -### ✅ Error Handling -- [ ] Invalid config path → Shows error -- [ ] Missing response file → Falls back to network -- [ ] DataStore error → Falls back to network - ---- - -## Test Files Available - -Located at: `sample/shared/src/commonMain/composeResources/files/networkmocks/` - -### JSONPlaceholder Host - -1. **getUser** (4 responses): - - `getUser-200.json` - Full user data - - `getUser-404.json` - Not found (simple) - - `getUser-404-detailed.json` - Not found (detailed) - - `getUser-500.json` - Server error - -2. **listUsers** (2 responses): - - `listUsers-200.json` - Array of 3 users - - `listUsers-200-empty.json` - Empty array - -3. **createPost** (3 responses): - - `createPost-201.json` - Created successfully - - `createPost-400.json` - Validation errors - - `createPost-401.json` - Unauthorized - -4. **getPost** (2 responses): - - `getPost-200.json` - Post data - - `getPost-404.json` - Not found - -### Staging Host - -5. **getUserProfile** (3 responses): - - `getUserProfile-200.json` - Full profile - - `getUserProfile-401.json` - Unauthorized - - `getUserProfile-404.json` - Not found - -6. **updateProfile** (2 responses): - - `updateProfile-200.json` - Success - - `updateProfile-400.json` - Validation errors - -**Total**: 18 response files across 6 endpoints! - ---- - -## Expected Console Output - -When mocking is active, you should see logs like: - -``` -[NetworkMock] Returning mock response for GET /users/1 - getUser-200.json -``` - -When mocking is disabled: - -``` -[NetworkMock] No mock enabled for GET /users/1, using actual network -``` - -When global mocking is off: - -``` -(No logs - plugin short-circuits early) -``` - ---- - -## Troubleshooting - -### Issue: DevView doesn't show Network Mock - -**Solution**: Check MainActivity.kt has: -```kotlin -module(module = NetworkMock) -``` - -### Issue: No endpoints visible - -**Solution**: Check test files exist at: -``` -sample/shared/src/commonMain/composeResources/files/networkmocks/mocks.json -``` - -### Issue: Mock not being returned - -**Checklist**: -1. Is global mocking ON? -2. Is endpoint mock toggle ON? -3. Is a response file selected? -4. Does the request URL match the config? -5. Check console logs for error messages - -### Issue: App crashes on API call - -**Check**: -- DataStore initialized correctly -- HttpClient created with mocking support -- SampleApi using the correct client instance - ---- - -## What's Next? - -### Add More Test Endpoints - -You can add more API calls to test: - -```kotlin -// In SampleApi.kt -public suspend fun listUsers(): String { - val response = client.get("https://jsonplaceholder.typicode.com/users") - return response.bodyAsText() -} - -public suspend fun createPost(title: String, body: String): String { - val response = client.post("https://jsonplaceholder.typicode.com/posts") { - contentType(ContentType.Application.Json) - setBody("""{"title":"$title","body":"$body","userId":1}""") - } - return response.bodyAsText() -} -``` - -Then add buttons in App.kt to test them! - -### Add Your Own Mock Configurations - -1. Update `mocks.json` with your API endpoints -2. Add response files following naming convention -3. They'll appear automatically in DevView! - ---- - -## Success Criteria - -✅ **All features working**: -- UI displays and controls work -- State persists across restarts -- Requests are intercepted when enabled -- Mock responses returned correctly -- Can toggle between mock and real API -- Multiple response options per endpoint -- Multi-host support functional - -✅ **No errors in console** - -✅ **Smooth user experience** - ---- - -## Summary - -🎉 **The Network Mock module is now FULLY FUNCTIONAL!** - -You can: -- ✅ Configure mocking through DevView UI -- ✅ Toggle individual endpoints on/off -- ✅ Select different mock responses -- ✅ Test success and error scenarios -- ✅ Switch between mock and real network -- ✅ Persist settings across app restarts - -**Ready for testing!** 🚀 - diff --git a/sample/network/build.gradle.kts b/sample/network/build.gradle.kts index 0ac6628..dcb7632 100644 --- a/sample/network/build.gradle.kts +++ b/sample/network/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } kotlin { - androidLibrary { + android { namespace = "com.worldline.devview.sample.network" androidResources { diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/README.md b/sample/network/src/commonMain/composeResources/files/networkmocks/README.md index 4735d76..19679c7 100644 --- a/sample/network/src/commonMain/composeResources/files/networkmocks/README.md +++ b/sample/network/src/commonMain/composeResources/files/networkmocks/README.md @@ -14,23 +14,38 @@ All mock responses follow **REST API best practices** with consistent JSON struc ``` composeResources/files/networkmocks/ -├── mocks.json # Main configuration file -└── responses/ # Mock response files organized by endpoint - ├── getUser/ - │ ├── getUser-200.json # Success response - │ ├── getUser-404.json # Not found (simple) - │ ├── getUser-404-detailed.json # Not found (detailed error) - │ └── getUser-500.json # Server error - ├── listUsers/ - │ ├── listUsers-200.json # List with 3 users - │ └── listUsers-200-empty.json # Empty list - ├── createPost/ - │ ├── createPost-201.json # Created successfully - │ ├── createPost-400.json # Validation error - │ └── createPost-401.json # Unauthorized - ├── getPost/ - │ ├── getPost-200.json # Success response - │ └── getPost-404.json # Post not found +├── mocks.json # Main configuration file +└── responses/ # Mock response files + ├── {groupId}/ # Shared responses (all environments) + │ └── {endpointId}/ + │ └── {endpointId}-{statusCode}[-{suffix}].json + └── {groupId}/ + └── {environmentId}/ # Environment-specific overrides + └── {endpointId}/ + └── {endpointId}-{statusCode}[-{suffix}].json +``` + +### Current sample layout + +``` +responses/ +├── jsonplaceholder/ +│ ├── getUser/ +│ │ ├── getUser-200.json # Success response +│ │ ├── getUser-404.json # Not found (simple) +│ │ ├── getUser-404-detailed.json # Not found (detailed error) +│ │ └── getUser-500.json # Server error +│ ├── listUsers/ +│ │ ├── listUsers-200.json # List with 3 users +│ │ └── listUsers-200-empty.json # Empty list +│ ├── createPost/ +│ │ ├── createPost-201.json # Created successfully +│ │ ├── createPost-400.json # Validation error +│ │ └── createPost-401.json # Unauthorized +│ └── getPost/ +│ ├── getPost-200.json # Success response +│ └── getPost-404.json # Post not found +└── example-api/ ├── getUserProfile/ │ ├── getUserProfile-200.json # Success response │ ├── getUserProfile-401.json # Unauthorized @@ -40,25 +55,25 @@ composeResources/files/networkmocks/ └── updateProfile-400.json # Validation error ``` -## Configured Hosts +## Configured API Groups -### 1. JSONPlaceholder (Public API) -- **Host ID**: `jsonplaceholder` -- **URL**: `https://jsonplaceholder.typicode.com` +### 1. JSONPlaceholder +- **Group ID**: `jsonplaceholder` +- **Environments**: `production` → `https://jsonplaceholder.typicode.com` - **Purpose**: Test with a real public API - **Endpoints**: - - `GET /users/{userId}` - Get user by ID - - `GET /users` - List all users - - `POST /posts` - Create a post - - `GET /posts/{postId}` - Get post by ID - -### 2. Staging Environment -- **Host ID**: `staging` -- **URL**: `https://staging.api.example.com` -- **Purpose**: Demonstrate multi-host configuration + - `GET /users/{userId}` — Get user by ID + - `GET /users` — List all users + - `POST /posts` — Create a post + - `GET /posts/{postId}` — Get post by ID + +### 2. Example API +- **Group ID**: `example-api` +- **Environments**: `staging` → `https://staging.api.example.com` +- **Purpose**: Demonstrate multi-group, multi-environment configuration - **Endpoints**: - - `GET /api/v1/profile/{userId}` - Get user profile - - `PUT /api/v1/profile` - Update profile + - `GET /api/v1/profile/{userId}` — Get user profile + - `PUT /api/v1/profile` — Update profile ## Usage Example @@ -96,7 +111,7 @@ val response = client.get("https://jsonplaceholder.typicode.com/users/1") - Open DevView in your app - Navigate to "Network Mock" screen - Toggle global mocking on/off -- Enable specific endpoint mocks +- Enable specific endpoint mocks per group and environment - Select which response to return (200, 404, 500, etc.) ## Testing Different Scenarios @@ -118,22 +133,22 @@ val response = client.get("https://jsonplaceholder.typicode.com/users/1") ## Path Parameters The configuration includes endpoints with path parameters: -- `/users/{userId}` - matches `/users/1`, `/users/123`, etc. -- `/posts/{postId}` - matches `/posts/1`, `/posts/456`, etc. -- `/api/v1/profile/{userId}` - matches any user ID +- `/users/{userId}` — matches `/users/1`, `/users/123`, etc. +- `/posts/{postId}` — matches `/posts/1`, `/posts/456`, etc. +- `/api/v1/profile/{userId}` — matches any user ID These demonstrate the plugin's ability to match requests with dynamic path segments. ## Adding Your Own Mocks -### 1. Add endpoint to mocks.json +### 1. Add to mocks.json ```json { - "hosts": [ + "apiGroups": [ { - "id": "your-host", - "url": "https://your-api.com", + "id": "your-api", + "name": "Your API", "endpoints": [ { "id": "yourEndpoint", @@ -141,6 +156,18 @@ These demonstrate the plugin's ability to match requests with dynamic path segme "path": "/api/your/path", "method": "GET" } + ], + "environments": [ + { + "id": "production", + "name": "Production", + "url": "https://your-api.com" + }, + { + "id": "staging", + "name": "Staging", + "url": "https://staging.your-api.com" + } ] } ] @@ -149,22 +176,30 @@ These demonstrate the plugin's ability to match requests with dynamic path segme ### 2. Create response files -Create folder: `responses/yourEndpoint/` +For responses shared across all environments, place them at: +``` +responses/your-api/yourEndpoint/ +├── yourEndpoint-200.json +├── yourEndpoint-404.json +└── yourEndpoint-500.json +``` -Add files following naming convention: -- `yourEndpoint-200.json` - Success -- `yourEndpoint-404.json` - Not found -- `yourEndpoint-500.json` - Server error +For environment-specific overrides (takes priority over shared): +``` +responses/your-api/staging/yourEndpoint/ +└── yourEndpoint-200.json # staging-specific 200 response +``` ### 3. Use in your app -The endpoint will automatically appear in the DevView Network Mock UI. +The endpoint will automatically appear in the DevView Network Mock UI under its group and environment tab. ## Notes - All response files contain raw JSON (response body only) - File names follow the pattern: `{endpointId}-{statusCode}[-{suffix}].json` -- Optional suffix helps differentiate multiple responses with the same status code +- Optional suffix helps differentiate multiple responses with the same status code (e.g. `getUser-404-detailed.json`) +- Environment-specific responses take priority over shared responses during discovery - The plugin discovers response files automatically based on naming convention diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/mocks.json b/sample/network/src/commonMain/composeResources/files/networkmocks/mocks.json index ef8a2c7..6de7b94 100644 --- a/sample/network/src/commonMain/composeResources/files/networkmocks/mocks.json +++ b/sample/network/src/commonMain/composeResources/files/networkmocks/mocks.json @@ -1,8 +1,8 @@ { - "hosts": [ + "apiGroups": [ { "id": "jsonplaceholder", - "url": "https://jsonplaceholder.typicode.com", + "name": "JSONPlaceholder", "endpoints": [ { "id": "getUser", @@ -28,11 +28,18 @@ "path": "/posts/{postId}", "method": "GET" } + ], + "environments": [ + { + "id": "production", + "name": "Production", + "url": "https://jsonplaceholder.typicode.com" + } ] }, { - "id": "staging", - "url": "https://staging.api.example.com", + "id": "sample-api", + "name": "Sample API", "endpoints": [ { "id": "getUserProfile", @@ -46,6 +53,24 @@ "path": "/api/v1/profile", "method": "PUT" } + ], + "environments": [ + { + "id": "staging", + "name": "Staging", + "url": "https://sample.api.staging.com" + }, + { + "id": "prod", + "name": "Production", + "url": "https://sample.api.com", + "endpointOverrides": [ + { + "id": "getUserProfile", + "path": "/api/v2/profile/{userId}" + } + ] + } ] } ] diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/createPost/createPost-201.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/createPost/createPost-201.json similarity index 100% rename from sample/network/src/commonMain/composeResources/files/networkmocks/responses/createPost/createPost-201.json rename to sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/createPost/createPost-201.json diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/createPost/createPost-400.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/createPost/createPost-400.json similarity index 100% rename from sample/network/src/commonMain/composeResources/files/networkmocks/responses/createPost/createPost-400.json rename to sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/createPost/createPost-400.json diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/createPost/createPost-401.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/createPost/createPost-401.json similarity index 100% rename from sample/network/src/commonMain/composeResources/files/networkmocks/responses/createPost/createPost-401.json rename to sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/createPost/createPost-401.json diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/getPost/getPost-200.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/getPost/getPost-200.json similarity index 100% rename from sample/network/src/commonMain/composeResources/files/networkmocks/responses/getPost/getPost-200.json rename to sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/getPost/getPost-200.json diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/getPost/getPost-404.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/getPost/getPost-404.json similarity index 100% rename from sample/network/src/commonMain/composeResources/files/networkmocks/responses/getPost/getPost-404.json rename to sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/getPost/getPost-404.json diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/getUser/getUser-200.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/getUser/getUser-200.json similarity index 100% rename from sample/network/src/commonMain/composeResources/files/networkmocks/responses/getUser/getUser-200.json rename to sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/getUser/getUser-200.json diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/getUser/getUser-404-detailed.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/getUser/getUser-404-detailed.json similarity index 100% rename from sample/network/src/commonMain/composeResources/files/networkmocks/responses/getUser/getUser-404-detailed.json rename to sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/getUser/getUser-404-detailed.json diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/getUser/getUser-404.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/getUser/getUser-404.json similarity index 100% rename from sample/network/src/commonMain/composeResources/files/networkmocks/responses/getUser/getUser-404.json rename to sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/getUser/getUser-404.json diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/getUser/getUser-500.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/getUser/getUser-500.json similarity index 100% rename from sample/network/src/commonMain/composeResources/files/networkmocks/responses/getUser/getUser-500.json rename to sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/getUser/getUser-500.json diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/listUsers/listUsers-200-empty.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/listUsers/listUsers-200-empty.json similarity index 100% rename from sample/network/src/commonMain/composeResources/files/networkmocks/responses/listUsers/listUsers-200-empty.json rename to sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/listUsers/listUsers-200-empty.json diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/listUsers/listUsers-200.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/listUsers/listUsers-200.json similarity index 100% rename from sample/network/src/commonMain/composeResources/files/networkmocks/responses/listUsers/listUsers-200.json rename to sample/network/src/commonMain/composeResources/files/networkmocks/responses/jsonplaceholder/listUsers/listUsers-200.json diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/getUserProfile/getUserProfile-200.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/getUserProfile/getUserProfile-200.json similarity index 100% rename from sample/network/src/commonMain/composeResources/files/networkmocks/responses/getUserProfile/getUserProfile-200.json rename to sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/getUserProfile/getUserProfile-200.json diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/getUserProfile/getUserProfile-401.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/getUserProfile/getUserProfile-401.json similarity index 100% rename from sample/network/src/commonMain/composeResources/files/networkmocks/responses/getUserProfile/getUserProfile-401.json rename to sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/getUserProfile/getUserProfile-401.json diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/getUserProfile/getUserProfile-404.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/getUserProfile/getUserProfile-404.json similarity index 100% rename from sample/network/src/commonMain/composeResources/files/networkmocks/responses/getUserProfile/getUserProfile-404.json rename to sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/getUserProfile/getUserProfile-404.json diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/prod/getUserProfile/getUserProfile-200.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/prod/getUserProfile/getUserProfile-200.json new file mode 100644 index 0000000..79f0d80 --- /dev/null +++ b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/prod/getUserProfile/getUserProfile-200.json @@ -0,0 +1,18 @@ +{ + "userId": "user-123-prod", + "profile": { + "firstName": "John", + "lastName": "Doe", + "email": "john.doe@example.com", + "avatarUrl": "https://example.com/avatars/user-123.jpg", + "bio": "Software developer and tech enthusiast", + "location": "San Francisco, CA", + "joinedDate": "2024-01-15T10:00:00Z" + }, + "settings": { + "emailNotifications": true, + "pushNotifications": false, + "theme": "dark" + } +} + diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/prod/getUserProfile/getUserProfile-401.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/prod/getUserProfile/getUserProfile-401.json new file mode 100644 index 0000000..a35616f --- /dev/null +++ b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/prod/getUserProfile/getUserProfile-401.json @@ -0,0 +1,12 @@ +{ + "userId": null, + "profile": null, + "settings": null, + "error": { + "code": "UNAUTHORIZED-prod", + "message": "Invalid or expired authentication token", + "statusCode": 401 + } +} + + diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/prod/getUserProfile/getUserProfile-404.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/prod/getUserProfile/getUserProfile-404.json new file mode 100644 index 0000000..ea08a7d --- /dev/null +++ b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/prod/getUserProfile/getUserProfile-404.json @@ -0,0 +1,11 @@ +{ + "userId": null, + "profile": null, + "settings": null, + "error": { + "code": "PROFILE_NOT_FOUND-prod", + "message": "User profile does not exist" + } +} + + diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/updateProfile/updateProfile-200.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/updateProfile/updateProfile-200.json similarity index 100% rename from sample/network/src/commonMain/composeResources/files/networkmocks/responses/updateProfile/updateProfile-200.json rename to sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/updateProfile/updateProfile-200.json diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/responses/updateProfile/updateProfile-400.json b/sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/updateProfile/updateProfile-400.json similarity index 100% rename from sample/network/src/commonMain/composeResources/files/networkmocks/responses/updateProfile/updateProfile-400.json rename to sample/network/src/commonMain/composeResources/files/networkmocks/responses/sample-api/updateProfile/updateProfile-400.json diff --git a/sample/shared/NETWORK_MOCK_INTEGRATION.md b/sample/shared/NETWORK_MOCK_INTEGRATION.md deleted file mode 100644 index c0e143e..0000000 --- a/sample/shared/NETWORK_MOCK_INTEGRATION.md +++ /dev/null @@ -1,188 +0,0 @@ -# Network Mock Integration in Sample App - -## ✅ Integration Complete - -The Network Mock module has been successfully integrated into the sample Android app! - -## What Was Done - -### 1. Module Registration ✅ - -**File**: `sample/androidApp/src/main/kotlin/.../MainActivity.kt` - -The NetworkMock module is now registered in DevView alongside FeatureFlip and Analytics: - -```kotlin -val modules = rememberModules { - module(module = FeatureFlip) - module(module = Analytics) - module(module = NetworkMock) // ✅ Added - module(module = TestModule) -} -``` - -### 2. Test API Endpoint ✅ - -**File**: `sample/shared/src/commonMain/kotlin/.../network/SampleApi.kt` - -Added a `getUser()` method that calls the JSONPlaceholder API: - -```kotlin -public suspend fun getUser(userId: Int): String { - val response = client.get("https://jsonplaceholder.typicode.com/users/$userId") - return response.bodyAsText() -} -``` - -This endpoint matches our test configuration in: -`sample/shared/src/commonMain/composeResources/files/networkmocks/mocks.json` - -### 3. UI Button ✅ - -**File**: `sample/shared/src/commonMain/kotlin/.../App.kt` - -Added a button to test the Network Mock feature: - -```kotlin -Button(onClick = { /* calls SampleApi().getUser(1) */ }) { - Text(text = "Test Network Mock (Get User)") -} -``` - -## How to Use - -### Step 1: Run the Sample App - -```bash -./gradlew :sample:androidApp:installDebug -``` - -### Step 2: Open DevView - -1. Launch the app -2. Click "Open DevView" button -3. Navigate to "Network Mock" in the menu - -### Step 3: Configure Mocking - -You'll see the "Get User by ID" endpoint from jsonplaceholder.typicode.com: - -1. **Toggle Global Mocking**: Turn ON to enable mocking -2. **Select Endpoint**: Find "Get User by ID" -3. **Enable Mock**: Toggle the switch to ON -4. **Select Response**: Choose from: - - `getUser-200.json` - Success response - - `getUser-404.json` - Not found error - - `getUser-404-detailed.json` - Detailed error - - `getUser-500.json` - Server error - -### Step 4: Test It! - -1. Go back to the main app screen -2. Click "Test Network Mock (Get User)" -3. See the response displayed -4. It will show the MOCK response instead of the real API! - -### Step 5: Try Different Scenarios - -**Success Response**: -- Select `getUser-200.json` -- Click the button -- See full user data with name, email, address, etc. - -**Error Response**: -- Select `getUser-404.json` -- Click the button -- See error message about user not found - -**Back to Real Network**: -- Toggle the mock OFF (or disable global mocking) -- Click the button -- See the actual response from jsonplaceholder.typicode.com - -## Current Limitation - -⚠️ **Important Note**: The Network Mock plugin is NOT currently installed in the HttpClient because it requires a DataStore instance, which is only available in Composable context. - -### The Issue - -```kotlin -// This doesn't work in BaseHttpClientConfig.kt (non-Composable): -install(NetworkMockPlugin) { - configPath = "files/networkmocks/mocks.json" - mockRepository = MockConfigRepository(configPath) - stateRepository = MockStateRepository(dataStore) // ❌ No DataStore here! -} -``` - -### The Solution (Architectural Refactoring Needed) - -This is the issue you identified earlier - network layer shouldn't depend on Compose! - -**Option 1**: Split the module into `-core` and `-ui` (recommended) -- `devview-networkmock-core` - No Compose, for network layer -- `devview-networkmock` - Compose UI only - -**Option 2**: Pass DataStore from outside -- Create DataStore at app level -- Pass it to HttpClient factory -- More complex but keeps single module - -**For now**, the module works perfectly through the DevView UI, but the plugin isn't actually intercepting requests yet. This will be fixed when we address the architectural concern you raised. - -## What Works Now - -✅ DevView UI displays Network Mock screen -✅ Configuration loads from test files -✅ All 6 endpoints visible (getUser, listUsers, createPost, etc.) -✅ Global toggle functional -✅ Per-endpoint toggles functional -✅ Response selection dropdowns working -✅ State persists in DataStore -✅ Reset functionality works -✅ Multi-host support visible (jsonplaceholder + staging) - -## What Doesn't Work Yet - -❌ Actual request interception (plugin not installed) -❌ Mock responses being returned -❌ Network calls still go to real APIs - -**Reason**: DataStore dependency issue - architectural split needed - -## Next Steps - -To make it fully functional: - -1. **Split the module** into `-core` and `-ui` (your suggestion) -2. **Install the plugin** in HttpClient with DataStore from app level -3. **Test end-to-end** with real request interception - -The UI is 100% ready, just needs the architectural fix! - -## Testing Without Plugin - -Even without the plugin installed, you can test the UI: - -1. Open DevView → Network Mock -2. Toggle mocking on/off -3. Select different responses -4. See state persist across app restarts -5. Verify all UI functionality - -The configuration, state management, and UI are all working perfectly! - ---- - -## Summary - -✅ **UI Integration**: Complete and functional -✅ **Module Registration**: Added to DevView -✅ **Test Files**: All 20 files ready in composeResources -✅ **Sample API**: Endpoint added for testing -✅ **Test Button**: UI button to trigger API call - -⚠️ **Plugin Installation**: Blocked by architectural issue (Compose in network layer) - -**Status**: Ready for architectural refactoring to enable full functionality! - diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index 82a33f0..447d9a4 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } kotlin { - androidLibrary { + android { namespace = "com.worldline.devview.sample.shared" } diff --git a/sample/shared/src/commonMain/kotlin/com/worldline/devview/sample/TestModule.kt b/sample/shared/src/commonMain/kotlin/com/worldline/devview/sample/TestModule.kt index ac57ca6..808c9a3 100644 --- a/sample/shared/src/commonMain/kotlin/com/worldline/devview/sample/TestModule.kt +++ b/sample/shared/src/commonMain/kotlin/com/worldline/devview/sample/TestModule.kt @@ -14,6 +14,7 @@ import com.worldline.devview.core.Module import com.worldline.devview.core.Section import com.worldline.devview.core.asDestination import com.worldline.devview.core.withTitle +import kotlin.reflect.KClass import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.persistentMapOf import kotlinx.serialization.Serializable @@ -39,11 +40,13 @@ public object TestModule : Module { override val section: Section get() = Section.CUSTOM - override val destinations: PersistentMap = persistentMapOf( + override val destinations: PersistentMap, DestinationMetadata> = persistentMapOf( TestModuleNavigation.Main.withTitle(title = "Test Module"), TestModuleNavigation.Detail.asDestination() ) + override val entryDestination: NavKey = TestModuleNavigation.Main + override val registerSerializers: PolymorphicModuleBuilder.() -> Unit get() = { subclass(