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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- NetworkMock: an operation can now simulate a network failure instead of returning a response,
two ways — **deterministically**, by selecting Timeout or Connection Refused in the operation
sheet's picker page (a new "Simulate Failure" section, alongside the response variants); or
**probabilistically**, via a new `x-devview.failureRate` (0.0–1.0) OpenAPI extension field,
which independently rolls on every otherwise-mocked request to that operation (an operation
left on `Network` passthrough is never affected). Each failure kind mirrors the exception a
real Ktor engine throws for the equivalent condition (`HttpRequestTimeoutException` for
Timeout, a connection-level `kotlinx.io.IOException` for Connection Refused), so existing app
error handling exercises the same code path. `OperationMockState` gains a `Failure(kind:
FailureKind)` variant (**breaking**: exhaustive `when` blocks over `OperationMockState` need a
new branch); `Operation` gains `failureRate: Double?`; `NetworkMockConfig` gains an injectable
`random: Random` for deterministic tests; `MockColorScheme` gains a `failure: StatusColors`
slot (**breaking**: new required constructor parameter). (`devview-networkmock-core`,
`devview-networkmock-ktor`, `devview-networkmock`, #88, #95)
- NetworkMock: mocked responses now serve the `Content-Type` derived from the spec's declared
media type (`responses.<code>.content.<mediaType>`, previously always hardcoded to
`application/json`) plus any additional headers declared on `responses.<code>.headers` — a
Expand Down
7 changes: 5 additions & 2 deletions devview-networkmock-core/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ Single test class:
| `MockMatch` | Returned by `findMatchingMock()`; carries `OperationKey` + resolved `Operation` — kept unrenamed, see naming note below |
| `OperationDescriptor` | Static `(key, config)` pair for an operation; used by the UI layer. Does not carry response variants — see below |
| `NetworkMockState` | Persisted state: `globalMockingEnabled`, `operationStates: Map<String, OperationMockState>`, `lastModified` |
| `OperationMockState` | Sealed interface: `Network` (pass-through) or `Mock(statusCode: Int, exampleName: String)` |
| `OperationMockState` | Sealed interface: `Network` (pass-through), `Mock(statusCode: Int, exampleName: String)`, or `Failure(kind: FailureKind)` (deterministic simulated network failure) |
| `FailureKind` | `@Serializable enum`: `TIMEOUT` / `CONNECTION_REFUSED` — mirrors what a real Ktor engine throws for the equivalent condition |

**Naming note**: `MockResponse` and `MockMatch` are deliberately *not* renamed to OpenAPI vocabulary — they model DevView's own runtime mocking behavior (a served response, a request-to-operation match), which OpenAPI has no concept of. Everything that models something the spec itself describes uses OpenAPI terms (`ApiSpec`, `Operation`, `OperationKey`).

Expand Down Expand Up @@ -102,7 +103,7 @@ There is no environment axis and no manifest file. `servers[]` lists every base
| `network_mock_schema_version` | Int | Gates the one-shot pre-0.2.0 migration |
| `network_mock_operation_{compositeKey}` | String (JSON) | `OperationMockState` per operation |

`OperationMockState` is serialized as `{"type":"network"}` or `{"type":"mock","statusCode":200,"exampleName":"default"}` (discriminator field `type`).
`OperationMockState` is serialized as `{"type":"network"}`, `{"type":"mock","statusCode":200,"exampleName":"default"}`, or `{"type":"failure","kind":"timeout"}` (discriminator field `type`).

Each operation is stored under its own key, so updating one operation never overwrites another.

Expand All @@ -114,6 +115,8 @@ Each operation is stored under its own key, so updating one operation never over

**`NetworkMockInitializer.initialize()` is `@Composable`** even though it is a process-level singleton. It uses `remember` internally so that the repo objects are tied to the Composition. Subsequent calls are early-returned no-ops (`if (stateRepository != null) return`).

**`Operation.failureRate` has no spec-wide default**, unlike `delayMs`. `DevViewExtension.failureRate` is parsed at the document root too but deliberately unused there — see the doc comment on `Operation.failureRate` for why (an operation-level-only knob is a much narrower blast radius than "some percentage of everything fails").

**`MockConfigRepository` caches** the parsed `MockConfiguration` in `cachedConfig` after the first successful load. Tests verify this with a recording resource loader that asserts each spec file is read exactly once.

**Response variant discovery is opt-in, not eager.** `OperationDescriptor` carries only `key` and `config` — no response list — because loading it requires real I/O (reading and decoding each `externalValue` file) on top of the one-time spec parse. Nothing in this module calls `discoverResponseFiles`/`loadMockResponse` automatically; `devview-networkmock`'s main operation list is built from parsed spec metadata alone, and only calls discovery for one operation when that operation's detail screen opens.
Expand Down
24 changes: 22 additions & 2 deletions devview-networkmock-core/api/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ package com.worldline.devview.networkmock.core.model {
property public java.util.List<java.lang.String> servers;
}

@kotlinx.serialization.Serializable public enum FailureKind {
method @InaccessibleFromKotlin public String getDisplayName();
property public String displayName;
enum_constant @kotlinx.serialization.SerialName("connection_refused") public static final com.worldline.devview.networkmock.core.model.FailureKind CONNECTION_REFUSED;
enum_constant @kotlinx.serialization.SerialName("timeout") public static final com.worldline.devview.networkmock.core.model.FailureKind TIMEOUT;
}

@kotlin.jvm.JvmInline @kotlinx.serialization.Serializable public final value class HttpMethod {
ctor @KotlinOnly public HttpMethod(String value);
method @InaccessibleFromKotlin public String getValue();
Expand Down Expand Up @@ -135,22 +142,25 @@ package com.worldline.devview.networkmock.core.model {
}

@androidx.compose.runtime.Immutable @kotlinx.serialization.Serializable public final class Operation {
ctor @KotlinOnly public Operation(String operationId, String name, String path, com.worldline.devview.networkmock.core.model.HttpMethod method, optional java.util.Map<java.lang.String,java.lang.String>? queryParameters, optional Long? delayMs, optional String? version);
ctor @KotlinOnly public Operation(String operationId, String name, String path, com.worldline.devview.networkmock.core.model.HttpMethod method, optional java.util.Map<java.lang.String,java.lang.String>? queryParameters, optional Long? delayMs, optional String? version, optional Double? failureRate);
method public String component1();
method public String component2();
method public String component3();
method @KotlinOnly public operator com.worldline.devview.networkmock.core.model.HttpMethod component4();
method public java.util.Map<java.lang.String,java.lang.String>? component5();
method public Long? component6();
method public String? component7();
method @KotlinOnly public com.worldline.devview.networkmock.core.model.Operation copy(optional String operationId, optional String name, optional String path, optional com.worldline.devview.networkmock.core.model.HttpMethod method, optional java.util.Map<java.lang.String,java.lang.String>? queryParameters, optional Long? delayMs, optional String? version);
method public Double? component8();
method @KotlinOnly public com.worldline.devview.networkmock.core.model.Operation copy(optional String operationId, optional String name, optional String path, optional com.worldline.devview.networkmock.core.model.HttpMethod method, optional java.util.Map<java.lang.String,java.lang.String>? queryParameters, optional Long? delayMs, optional String? version, optional Double? failureRate);
method @InaccessibleFromKotlin public Long? getDelayMs();
method @InaccessibleFromKotlin public Double? getFailureRate();
method @InaccessibleFromKotlin public String getName();
method @InaccessibleFromKotlin public String getOperationId();
method @InaccessibleFromKotlin public String getPath();
method @InaccessibleFromKotlin public java.util.Map<java.lang.String,java.lang.String>? getQueryParameters();
method @InaccessibleFromKotlin public String? getVersion();
property public Long? delayMs;
property public Double? failureRate;
property public com.worldline.devview.networkmock.core.model.HttpMethod method;
property public String name;
property public String operationId;
Expand Down Expand Up @@ -200,6 +210,16 @@ package com.worldline.devview.networkmock.core.model {
property public abstract String displayName;
}

@androidx.compose.runtime.Immutable @kotlinx.serialization.SerialName("failure") @kotlinx.serialization.Serializable public static final class OperationMockState.Failure implements com.worldline.devview.networkmock.core.model.OperationMockState {
ctor public OperationMockState.Failure(com.worldline.devview.networkmock.core.model.FailureKind kind);
method public com.worldline.devview.networkmock.core.model.FailureKind component1();
method public com.worldline.devview.networkmock.core.model.OperationMockState.Failure copy(optional com.worldline.devview.networkmock.core.model.FailureKind kind);
method @InaccessibleFromKotlin public String getDisplayName();
method @InaccessibleFromKotlin public com.worldline.devview.networkmock.core.model.FailureKind getKind();
property public String displayName;
property public com.worldline.devview.networkmock.core.model.FailureKind kind;
}

@androidx.compose.runtime.Immutable @kotlinx.serialization.SerialName("mock") @kotlinx.serialization.Serializable public static final class OperationMockState.Mock implements com.worldline.devview.networkmock.core.model.OperationMockState {
ctor public OperationMockState.Mock(int statusCode, String exampleName);
method public int component1();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ public data class ApiSpec(
* [com.worldline.devview.networkmock.core.repository.RequestMatcher]). The pattern is not
* currently configurable; non-standard (header- or query-versioned) APIs simply get
* `null` here.
* @property failureRate Probability (0.0–1.0) that an otherwise-mocked request to this
* operation independently fails instead, from the operation-level `x-devview.failureRate`
* extension. `null` (the default) means every request behaves normally. Unlike [delayMs],
* this has no spec-wide default on [ApiSpec] — "some percentage of everything fails" is a
* much blunter tool than "this specific flaky endpoint fails sometimes".
* @see ApiSpec
* @see com.worldline.devview.networkmock.core.repository.RequestMatcher
*/
Expand All @@ -86,7 +91,8 @@ public data class Operation(
val method: HttpMethod,
val queryParameters: Map<String, String>? = null,
val delayMs: Long? = null,
val version: String? = null
val version: String? = null,
val failureRate: Double? = null
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,18 @@ public data class NetworkMockState(
/**
* Represents the mocking state for a single API operation.
*
* Each operation is either passing traffic through to the actual network or returning a
* specific mock response. The two variants are represented as distinct types, eliminating
* any ambiguous state combinations that existed in a previous boolean-flag approach.
* Each operation is either passing traffic through to the actual network, returning a
* specific mock response, or simulating a network failure. The variants are represented as
* distinct types, eliminating any ambiguous state combinations that existed in a previous
* boolean-flag approach.
*
* ## Variants
*
* | Variant | Behavior | [displayName] |
* |---------|----------|---------------|
* | [Network] | All requests pass through to the actual network (default) | `"Network"` |
* | [Mock] | Requests return the selected response variant | `"$statusCode - $exampleName"` |
* | [Failure] | Requests fail with the selected [FailureKind] | e.g. `"Timeout"` |
*
* @see NetworkMockState
*/
Expand All @@ -100,6 +102,7 @@ public sealed interface OperationMockState {
*
* - [Network]: always `"Network"`
* - [Mock]: `"$statusCode - $exampleName"` (e.g. `"200 - default"`)
* - [Failure]: the selected [FailureKind]'s own display name (e.g. `"Timeout"`)
*/
public val displayName: String

Expand Down Expand Up @@ -135,4 +138,45 @@ public sealed interface OperationMockState {
public data class Mock(val statusCode: Int, val exampleName: String) : OperationMockState {
override val displayName: String get() = "$statusCode - $exampleName"
}

/**
* The operation will deterministically simulate a network failure instead of returning
* any response — every request to it fails the same way, the same way [Mock] always
* serves the same response.
*
* See #95's `x-devview.failureRate` for the complementary *probabilistic* failure —
* this variant answers "make this endpoint always fail right now", that one answers
* "make this endpoint flaky, the way a real degraded service is."
*
* @property kind The kind of network failure to simulate
* @see FailureKind
*/
@Immutable
@Serializable
@SerialName("failure")
public data class Failure(val kind: FailureKind) : OperationMockState {
override val displayName: String get() = kind.displayName
}
}

/**
* A network failure mode an operation can be configured to simulate deterministically via
* [OperationMockState.Failure].
*
* Each kind mirrors the exception a real Ktor HTTP engine (OkHttp on Android, Darwin on iOS)
* throws for the equivalent real condition, so an app's existing error handling for that
* condition exercises the same code path against the simulated failure as it would against
* the real one. See `NetworkMockPlugin`'s `simulatedFailure` in `devview-networkmock-ktor`.
*
* @property displayName Human-readable name for UI display (e.g. `"Timeout"`)
*/
@Serializable
public enum class FailureKind(public val displayName: String) {
/** Mirrors `io.ktor.client.plugins.HttpRequestTimeoutException` — the request timed out. */
@SerialName("timeout")
TIMEOUT(displayName = "Timeout"),

/** Mirrors a connection-level I/O failure (e.g. connection refused, host unreachable). */
@SerialName("connection_refused")
CONNECTION_REFUSED(displayName = "Connection Refused")
}
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,10 @@ internal data class ComponentsObject(
)

/**
* The `x-devview` Specification Extension object (see #94). Read at both the document root
* (spec-wide default delay) and per-operation (overrides the document default).
* The `x-devview` Specification Extension object (see #94). [delayMs] is read at both the
* document root (spec-wide default delay) and per-operation (overrides the document default).
* [failureRate] is operation-level only (see [com.worldline.devview.networkmock.core.model.Operation.failureRate]) —
* it is ignored if declared at the document root.
*/
@Serializable
internal data class DevViewExtension(val delayMs: Long? = null)
internal data class DevViewExtension(val delayMs: Long? = null, val failureRate: Double? = null)
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ internal object OpenApiParser {
method = method,
queryParameters = queryParameters,
delayMs = rawOperation.xDevview?.delayMs,
version = versionPattern.find(input = path)?.groupValues?.get(index = 1)
version = versionPattern.find(input = path)?.groupValues?.get(index = 1),
failureRate = rawOperation.xDevview?.failureRate
)

responseIndex[operationId] = context.resolveResponseIndex(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,37 @@ class MockConfigRepositoryTest {
withoutOwnDelay?.delayMs shouldBe 200
}

@Test
fun `x-devview failureRate is parsed as an operation-level field with no spec-wide default`() = runTest {
val spec = """
{
"info": { "title": "Example" },
"servers": [ { "url": "https://api.example.com" } ],
"x-devview": { "failureRate": 0.5 },
"paths": {
"/api/flaky": {
"get": {
"operationId": "flaky",
"x-devview": { "failureRate": 0.1 },
"responses": {}
}
},
"/api/steady": {
"get": { "operationId": "steady", "responses": {} }
}
}
}
""".trimIndent()
val repository = createRepository(resources = mapOf(SPEC_PATH to spec))

val config = repository.loadConfiguration().getOrThrow()
val operations = config.specs[0].operations.associateBy { it.operationId }

// Unlike delayMs, a document-root failureRate is not a spec-wide default.
operations.getValue("flaky").failureRate shouldBe 0.1
operations.getValue("steady").failureRate shouldBe null
}

@Test
fun `operation version is extracted from a v-n path segment`() = runTest {
val cases = mapOf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import app.cash.turbine.test
import com.worldline.devview.networkmock.core.fixtures.MockTestData
import com.worldline.devview.networkmock.core.fixtures.ThrowingPreferencesDataStore
import com.worldline.devview.networkmock.core.model.FailureKind
import com.worldline.devview.networkmock.core.model.OperationKey
import com.worldline.devview.networkmock.core.model.OperationMockState
import com.worldline.devview.test.FakePreferencesDataStore
Expand Down Expand Up @@ -114,6 +115,20 @@ class MockStateRepositoryTest {
operationState shouldBe OperationMockState.Network
}

@Test
fun `setOperationMockState persists failure state for an operation`() = runTest {
val repository = createRepository()

repository.setOperationMockState(
key = key(operationId = "getUser"),
state = OperationMockState.Failure(kind = FailureKind.TIMEOUT)
)

val operationState = repository.getState().getOperationState(key = key(operationId = "getUser"))
operationState.shouldBeInstanceOf<OperationMockState.Failure>()
operationState.kind shouldBe FailureKind.TIMEOUT
}

@Test
fun `setOperationMockState is reflected in observeState`() = runTest {
val repository = createRepository()
Expand Down
Loading
Loading