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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- NetworkMock: operations can now declare OpenAPI `tags`, read verbatim into a new
`Operation.tags: List<String>` (empty by default, display-only — no effect on request
matching, same as `Operation.version`). The operation list gains a fourth per-tab filter chip
row (`tag_filter_row`, multi-select, hidden when the current spec has no tagged operations)
and a sort control — a new "Sort" toolbar dropdown, wired via the shared toolbar's new
`DestinationMetadataBuilder.menu` action (`NetworkMock` exposes a `sortSharedFlow`,
`NetworkMockScreen` collects it). Tapping it opens a menu with one entry per sort key: spec
order (default), path (A-Z), method (`HttpMethod.DefaultMethods` order), and tag (an
operation's first declared tag); picking an entry sets that sort directly. Sort
selection is per-tab, plain client-side state in `NetworkMockScreen`'s `ContentState`, not
the ViewModel — same convention as the existing filters. See
`docs/modules/networkmock-core.md`'s new "Tags" section and `docs/modules/networkmock-ui.md`.
(`devview`, `devview-networkmock-core`, `devview-networkmock`, #116, #117)
- DevView: the shared top app bar's contextual actions can now be a dropdown menu of discrete
choices, not just a single-tap icon or a confirm/cancel popup. `ModuleDestinationAction` gains
a `menuItems: PersistentList<ModuleDestinationActionMenuItem>?` property (new public class);
`DestinationMetadataBuilder` gains a `menu(icon) { item(label) { ... } }` DSL alongside the
existing `action`. Precedence when both `action` and `menu` could apply: `menuItems` wins,
then `popup`, then the plain `action` lambda. (`devview`, #117)

- NetworkMock: an operation can now declare narrow request-body match constraints — required
top-level fields and/or a discriminator field's value, read from its
`requestBody.content.<mediaType>.schema` — to disambiguate operations that would otherwise
Expand Down Expand Up @@ -98,6 +118,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
or silence this via `Logger.setMinSeverity(...)`; `devview-consolelogger`, if installed,
captures it automatically. Detekt's `ForbiddenMethodCall` rule (`println`/`print`) is now
enforced repo-wide. (`devview-networkmock-core`, `devview-networkmock-ktor`, #86)
- NetworkMock: the bottom bar's search field and expand-filter button were padded as a whole
`Surface`, pushing every filter chip row above them down by the system navigation bar inset
as well — the inset now only pads the search field and button themselves, matching
`AnalyticsScreen`'s existing (correct) layout. (`devview-networkmock`)

## [0.2.0-alpha03] - 2026-09-11

Expand Down
7 changes: 5 additions & 2 deletions devview-networkmock-core/api/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,9 @@ 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, optional Double? failureRate, optional com.worldline.devview.networkmock.core.model.RequestBodyMatch? requestBodyMatch);
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, optional com.worldline.devview.networkmock.core.model.RequestBodyMatch? requestBodyMatch, optional java.util.List<java.lang.String> tags);
method public String component1();
method public java.util.List<java.lang.String> component10();
method public String component2();
method public String component3();
method @KotlinOnly public operator com.worldline.devview.networkmock.core.model.HttpMethod component4();
Expand All @@ -155,14 +156,15 @@ package com.worldline.devview.networkmock.core.model {
method public String? component7();
method public Double? component8();
method public com.worldline.devview.networkmock.core.model.RequestBodyMatch? component9();
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, optional com.worldline.devview.networkmock.core.model.RequestBodyMatch? requestBodyMatch);
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, optional com.worldline.devview.networkmock.core.model.RequestBodyMatch? requestBodyMatch, optional java.util.List<java.lang.String> tags);
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 com.worldline.devview.networkmock.core.model.RequestBodyMatch? getRequestBodyMatch();
method @InaccessibleFromKotlin public java.util.List<java.lang.String> getTags();
method @InaccessibleFromKotlin public String? getVersion();
property public Long? delayMs;
property public Double? failureRate;
Expand All @@ -172,6 +174,7 @@ package com.worldline.devview.networkmock.core.model {
property public String path;
property public java.util.Map<java.lang.String,java.lang.String>? queryParameters;
property public com.worldline.devview.networkmock.core.model.RequestBodyMatch? requestBodyMatch;
property public java.util.List<java.lang.String> tags;
property public String? version;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ public data class RequestBodyMatch(
* here matches any request body, mirroring how `null` [queryParameters] matches any query
* string. Exists to disambiguate operations that would otherwise collide on path, method,
* and query alone (see [RequestBodyMatch]).
* @property tags Display-only labels from the operation's OpenAPI `tags` array, or an empty
* list if none are declared. Like [version], this has no effect on request matching — it
* drives the NetworkMock UI's tag filter chips and the "Tag" sort option only (see
* `devview-networkmock`'s `NetworkMockScreen`).
* @see ApiSpec
* @see com.worldline.devview.networkmock.core.repository.RequestMatcher
*/
Expand All @@ -129,7 +133,8 @@ public data class Operation(
val delayMs: Long? = null,
val version: String? = null,
val failureRate: Double? = null,
val requestBodyMatch: RequestBodyMatch? = null
val requestBodyMatch: RequestBodyMatch? = null,
val tags: List<String> = emptyList()
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@ import kotlinx.serialization.Serializable
* that kaml does not provide for `kotlinx.serialization.json.JsonElement`-shaped values.
*
* Only fields consumed by [OpenApiParser] are modeled. Everything else in a real spec
* (`deprecated`, `tags`, `security`, …) is silently ignored via lenient/non-strict decoding —
* this parser mocks, it does not validate. [SchemaObject] is the one exception, read in two
* (`deprecated`, `security`, …) is silently ignored via lenient/non-strict decoding — this
* parser mocks, it does not validate. [SchemaObject] is the one exception, read in two
* narrow ways: to *synthesize* a response body when a spec declares no `examples` for a status
* code (see [SchemaSynthesizer]), and to build a [RequestBodyObject]'s match constraints (see
* [OpenApiParser]'s request-body matching scope decision) — neither is full validation.
* `tags` (see [OperationObject.tags]) is also read, purely as a display/filter label for
* `devview-networkmock`'s UI — like [ParameterObject.example], it has no effect on request
* matching.
*/
@Serializable
internal data class OpenApiDocument(
Expand Down Expand Up @@ -72,6 +75,7 @@ internal data class PathItemObject(
internal data class OperationObject(
val operationId: String? = null,
val summary: String? = null,
val tags: List<String> = emptyList(),
val parameters: List<ParameterObject> = emptyList(),
val requestBody: RequestBodyObject? = null,
val responses: Map<String, ResponseObject> = emptyMap(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ internal data class ResolvedResponse(
* null` (matches any body), same as an operation declaring no `requestBody` at all. Only one
* media type is read per `requestBody` (`application/json` if declared, otherwise whichever
* is declared first).
* - `tags` (see #116) is read verbatim into [Operation.tags], purely a display/filter label for
* `devview-networkmock`'s UI (tag filter chips, "Tag" sort option) — it has no effect on
* request matching, same as [Operation.version].
*/
internal object OpenApiParser {
/**
Expand Down Expand Up @@ -120,7 +123,8 @@ internal object OpenApiParser {
requestBodyMatch = context.buildRequestBodyMatch(
raw = rawOperation.requestBody,
document = document
)
),
tags = rawOperation.tags
)

responseIndex[operationId] = context.resolveResponseIndex(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,38 @@ class MockConfigRepositoryTest {
versionByPath shouldBe cases
}

@Test
fun `operation tags are parsed from the OpenAPI tags array`() = runTest {
val spec = """
{
"info": { "title": "Example" },
"servers": [ { "url": "https://api.example.com" } ],
"paths": {
"/api/users": {
"get": {
"operationId": "listUsers",
"tags": ["Users", "Admin"],
"responses": {}
},
"post": {
"operationId": "createUser",
"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 }

operations.getValue("listUsers").tags shouldContainExactly listOf("Users", "Admin")
// No tags declared at all - defaults to an empty list, not null, same as queryParameters
// defaulting to null rather than every operation carrying a placeholder.
operations.getValue("createUser").tags shouldBe emptyList()
}

@Test
fun `local dollar-ref to a components response resolves correctly`() = runTest {
val spec = $$"""
Expand Down Expand Up @@ -1017,7 +1049,7 @@ class MockConfigRepositoryTest {
// boolean (a different concept from schema.required, and not modeled at all - must be
// silently ignored), three status codes all $ref-ing the *same* response schema, a
// folded (unquoted, line-wrapped) summary string, a double-quoted description with a
// backslash line continuation, and a tags block sequence (unmodeled until #116/PR 10).
// backslash line continuation, and a tags block sequence.
val yamlSpec = $$"""
info:
title: Example
Expand Down Expand Up @@ -1091,6 +1123,7 @@ class MockConfigRepositoryTest {
operation.name shouldBe "Init mobile authentication activation workflow. It will reset " +
"any previously activated mobile authentication for this user and device."
operation.requestBodyMatch?.requiredFields shouldContainExactly listOf("deviceId")
operation.tags shouldContainExactly listOf("Authentication V1", "Authentication")

val responses = repository.discoverResponseFiles(
key = OperationKey(specId = "example", operationId = "mobileLogin")
Expand Down
31 changes: 28 additions & 3 deletions devview-networkmock/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,11 @@ version/method it stays selected across tab switches.
Only the search field and a chevron `IconButton` are visible by default (mirrors
`devview-analytics`'s `AnalyticsScreen` bottom bar). Tapping the chevron toggles `filtersExpanded`,
revealing — top to bottom — the mock-state filter row, the version filter row (if the current
spec has versioned operations), then the method filter row (if it has more than one method) inside
an `AnimatedVisibility`. The chevron rotates via `graphicsLayer(rotationX = ...)` driven by
`animateFloatAsState`, identical to the Analytics pattern.
spec has versioned operations), the method filter row (if it has more than one method), then the
tag filter row (if the current spec has any tagged operations — see
[Tags](../docs/modules/networkmock-core.md#tags)) inside an `AnimatedVisibility`. The chevron
rotates via `graphicsLayer(rotationX = ...)` driven by `animateFloatAsState`, identical to the
Analytics pattern.

### Global mocked-count header

Expand Down Expand Up @@ -121,6 +123,29 @@ deliberately don't (see "Status code colors and icons" below).

Wired via a `MutableSharedFlow<Unit>` (capacity 1, `DROP_OLDEST`) created in `NetworkMock` and passed into `NetworkMockScreen`. `resetAllToNetwork()` resets every operation in the parsed config (not just those stored in DataStore) to avoid gaps for operations the user has never touched.

### "Sort" toolbar dropdown

Registered via `DestinationMetadataBuilder.menu` (the shared toolbar's dropdown-menu action —
see `devview/DevView.kt` and `ModuleDestinationAction.menuItems`), not the plain single-tap
`action` used by "Refresh"/"Reset to Network" — a sort key is a discrete choice among several, so
it gets an anchored dropdown instead of a cycling single-tap icon. One menu entry per
`OperationSort` value (`Default`, `Path (A-Z)`, `Method`, `Tag`), built once at module-construction
time from `OperationSort.entries`; because the shared `menu` DSL takes a fixed list of items with
no access to the currently-visible spec, "Tag" is always offered even when the current spec has no
tagged operations — picking it in that case is a harmless no-op (`sortedByOption` sorts by every
operation's absent first tag, i.e. an equal empty string for all, so the list order doesn't
change).

The flow crossing `NetworkMock` → `NetworkMockScreen` carries an `OperationSort.label: String`,
not `OperationSort` itself: `NetworkMockScreen` is public API but `OperationSort` is deliberately
`internal` (see "Search and filters live in the composable, not the ViewModel" above), and a
public composable can't expose an internal type in its signature. `ContentState` maps the label
back to the enum entry via `OperationSort.entries.firstOrNull { it.label == label }`; both ends of
this flow live in this module and share the same `OperationSort.label` values, so the label is a
safe, internal-only protocol despite the public parameter type. Sort selection itself is stored
the same way as the version/method/tag filters — plain `mutableStateMapOf<String, OperationSort>`
in `ContentState`, keyed by spec ID, never round-tripped through `NetworkMockViewModel`.

### Operation sheet: one sheet, two pages

`NetworkMockOperationSheet.kt` renders `NetworkMockViewModel.sheetState` as a `ModalBottomSheet`
Expand Down
2 changes: 1 addition & 1 deletion devview-networkmock/api/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ package com.worldline.devview.networkmock {
}

public final class NetworkMockScreenKt {
method @KotlinOnly @androidx.compose.runtime.Composable public static void NetworkMockScreen(kotlinx.coroutines.flow.SharedFlow<kotlin.Unit> resetToNetworkSharedFlow, kotlinx.coroutines.flow.SharedFlow<kotlin.Unit> reloadConfigSharedFlow, com.worldline.devview.networkmock.viewmodel.NetworkMockViewModel viewModel, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.unit.Dp bottomPadding);
method @KotlinOnly @androidx.compose.runtime.Composable public static void NetworkMockScreen(kotlinx.coroutines.flow.SharedFlow<kotlin.Unit> resetToNetworkSharedFlow, kotlinx.coroutines.flow.SharedFlow<kotlin.Unit> reloadConfigSharedFlow, kotlinx.coroutines.flow.SharedFlow<java.lang.String> sortSharedFlow, com.worldline.devview.networkmock.viewmodel.NetworkMockViewModel viewModel, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.unit.Dp bottomPadding);
}

}
Expand Down
Loading
Loading