From 8810dae38307c0b9f22cf45ea86cfdc04461ab21 Mon Sep 17 00:00:00 2001 From: Maxime MICHEL Date: Wed, 23 Sep 2026 16:41:19 +0200 Subject: [PATCH 1/4] :sparkles: Match operations on request body shape Two operations that collide on path, method, and query (e.g. two specs sharing a host, each declaring POST /api/payments) can now be disambiguated by their requestBody shape. A new RequestBodyMatch (requiredFields, discriminatorField, discriminatorValue) is built from an operation requestBody content schema (application/json preferred, otherwise the first declared media type): required fields from the schema required array, and a discriminator literal value from that property own single-value enum. This is narrow matching, not full JSON Schema validation - an operation whose schema yields neither is unaffected (requestBodyMatch stays null, matching any body, same as declaring no requestBody at all). OpenApiDocument gains RequestBodyObject and SchemaObject.required; OperationObject gains requestBody; ComponentsObject gains requestBodies for ref resolution via the existing resolveRef machinery. RequestMatcher gains matchesRequestBody; MockConfigRepository.findMatchingMock gains an optional requestBody parameter threaded into its operation-matching check. devview-networkmock-ktor plugin reads the request body only when it is already a fully in-memory OutgoingContent.ByteArrayContent, the shape Ktor content negotiation produces for a JSON-serialized body - bytes() is a pure, repeatable read, so this never consumes or mutates anything execute() still needs to send. Any other content shape (streaming, multipart, none) is treated as no body rather than risking corruption of live traffic. api.txt regenerated for devview-networkmock-core; docs/modules/networkmock-core.md gains a Request body matching section. --- CHANGELOG.md | 14 ++ devview-networkmock-core/api/api.txt | 25 ++- .../core/model/MockConfiguration.kt | 39 +++- .../core/openapi/OpenApiDocument.kt | 53 +++-- .../networkmock/core/openapi/OpenApiParser.kt | 81 ++++++- .../core/repository/MockConfigRepository.kt | 20 +- .../core/repository/RequestMatcher.kt | 57 +++++ .../repository/MockConfigRepositoryTest.kt | 208 ++++++++++++++++++ .../core/repository/RequestMatcherTest.kt | 131 +++++++++++ .../ktor/plugin/NetworkMockPluginTest.kt | 168 ++++++++++++++ .../ktor/plugin/NetworkMockPlugin.kt | 33 ++- docs/modules/networkmock-core.md | 50 ++++- 12 files changed, 852 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c3c14de..d4731a1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 declare narrow request-body match constraints — required + top-level fields and/or a discriminator field's value, read from its + `requestBody.content..schema` — to disambiguate operations that would otherwise + collide on path, method, and query alone (e.g. two specs sharing a host, each declaring + `POST /api/payments`, differing only by body shape). Not full JSON Schema validation; an + operation declaring no `requestBody`, or one with neither a required field nor a usable + discriminator, matches any body, same as before. `Operation` gains `requestBodyMatch: + RequestBodyMatch?` (new public class); `RequestMatcher` gains `matchesRequestBody`; + `MockConfigRepository.findMatchingMock` gains an optional `requestBody: String?` parameter. + `devview-networkmock-ktor`'s plugin reads the request body only when it's already a fully + in-memory `OutgoingContent.ByteArrayContent` — a pure, repeatable read that never consumes or + mutates anything the real network call still needs to send. See + `docs/modules/networkmock-core.md`'s new "Request body matching" section. + (`devview-networkmock-core`, `devview-networkmock-ktor`, #83) - NetworkMock: a status code with a declared `content..schema` but no `examples` now synthesizes a placeholder response body instead of being unmockable — primitives, `enum` (first value), `object`/`array` (recursively, by declared `type` or by the mere presence of diff --git a/devview-networkmock-core/api/api.txt b/devview-networkmock-core/api/api.txt index dd3d7669..73d648ad 100644 --- a/devview-networkmock-core/api/api.txt +++ b/devview-networkmock-core/api/api.txt @@ -145,7 +145,7 @@ 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? queryParameters, optional Long? delayMs, optional String? version, optional Double? failureRate); + ctor @KotlinOnly public Operation(String operationId, String name, String path, com.worldline.devview.networkmock.core.model.HttpMethod method, optional java.util.Map? queryParameters, optional Long? delayMs, optional String? version, optional Double? failureRate, optional com.worldline.devview.networkmock.core.model.RequestBodyMatch? requestBodyMatch); method public String component1(); method public String component2(); method public String component3(); @@ -154,13 +154,15 @@ package com.worldline.devview.networkmock.core.model { method public Long? component6(); method public String? component7(); 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? queryParameters, optional Long? delayMs, optional String? version, optional Double? failureRate); + 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? queryParameters, optional Long? delayMs, optional String? version, optional Double? failureRate, optional com.worldline.devview.networkmock.core.model.RequestBodyMatch? requestBodyMatch); 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? getQueryParameters(); + method @InaccessibleFromKotlin public com.worldline.devview.networkmock.core.model.RequestBodyMatch? getRequestBodyMatch(); method @InaccessibleFromKotlin public String? getVersion(); property public Long? delayMs; property public Double? failureRate; @@ -169,6 +171,7 @@ package com.worldline.devview.networkmock.core.model { property public String operationId; property public String path; property public java.util.Map? queryParameters; + property public com.worldline.devview.networkmock.core.model.RequestBodyMatch? requestBodyMatch; property public String? version; } @@ -257,6 +260,21 @@ package com.worldline.devview.networkmock.core.model { property public java.util.List responses; } + @androidx.compose.runtime.Immutable @kotlinx.serialization.Serializable public final class RequestBodyMatch { + ctor public RequestBodyMatch(); + ctor public RequestBodyMatch(optional java.util.List requiredFields, optional String? discriminatorField, optional String? discriminatorValue); + method public java.util.List component1(); + method public String? component2(); + method public String? component3(); + method public com.worldline.devview.networkmock.core.model.RequestBodyMatch copy(optional java.util.List requiredFields, optional String? discriminatorField, optional String? discriminatorValue); + method @InaccessibleFromKotlin public String? getDiscriminatorField(); + method @InaccessibleFromKotlin public String? getDiscriminatorValue(); + method @InaccessibleFromKotlin public java.util.List getRequiredFields(); + property public String? discriminatorField; + property public String? discriminatorValue; + property public java.util.List requiredFields; + } + public enum StatusCodeFamily { method @InaccessibleFromKotlin public String getDisplayName(); property public String displayName; @@ -280,7 +298,7 @@ package com.worldline.devview.networkmock.core.repository { public final class MockConfigRepository { ctor public MockConfigRepository(java.util.List specPaths, com.worldline.devview.networkmock.core.NetworkMockResourceLoader resourceLoader); method public suspend Object? discoverResponseFiles(com.worldline.devview.networkmock.core.model.OperationKey key, kotlin.coroutines.Continuation>); - method public suspend Object? findMatchingMock(String host, String path, String method, optional java.util.Map> queryParameters, kotlin.coroutines.Continuation); + method public suspend Object? findMatchingMock(String host, String path, String method, optional java.util.Map> queryParameters, optional String? requestBody, kotlin.coroutines.Continuation); method public void invalidate(); method @KotlinOnly public suspend Object? loadConfiguration(kotlin.coroutines.Continuation>); method public suspend Object? loadMockResponse(com.worldline.devview.networkmock.core.model.OperationKey key, int statusCode, String exampleName, kotlin.coroutines.Continuation); @@ -303,6 +321,7 @@ package com.worldline.devview.networkmock.core.repository { public final class RequestMatcher { method public boolean matchesPath(String configPath, String requestPath); method public boolean matchesQueryParams(java.util.Map? configQueryParams, java.util.Map> requestQueryParams); + method public boolean matchesRequestBody(com.worldline.devview.networkmock.core.model.RequestBodyMatch? configMatch, String? requestBody); field public static final com.worldline.devview.networkmock.core.repository.RequestMatcher INSTANCE; } diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt index b2ddfa67..e51f7aa0 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt @@ -51,6 +51,36 @@ public data class ApiSpec( val delayMs: Long? = null ) +/** + * Narrow request-body match constraints for an [Operation], built from its OpenAPI + * `requestBody.content..schema` (see + * [com.worldline.devview.networkmock.core.openapi.OpenApiParser]). + * + * This is **not** JSON Schema validation — only required-field presence and, optionally, a + * single discriminator field's value are checked (see + * [com.worldline.devview.networkmock.core.repository.RequestMatcher.matchesRequestBody]), + * consistent with how lenient this library's existing path/query matching already is. + * + * @property requiredFields Top-level property names the request body's JSON object must + * contain, from the schema's `required` array. Empty if the schema declares none. + * @property discriminatorField The schema's `discriminator.propertyName`, or `null` if the + * schema declares no discriminator. When non-null, the request body must contain this + * property (with any value, unless [discriminatorValue] narrows it further) to match. + * @property discriminatorValue The literal value [discriminatorField] must equal, sourced from + * that property's own single-value `enum` at parse time — `null` if the discriminator + * property doesn't declare one, in which case only [discriminatorField]'s *presence* is + * checked, not its value. + * @see Operation.requestBodyMatch + * @see com.worldline.devview.networkmock.core.repository.RequestMatcher.matchesRequestBody + */ +@Immutable +@Serializable +public data class RequestBodyMatch( + val requiredFields: List = emptyList(), + val discriminatorField: String? = null, + val discriminatorValue: String? = null +) + /** * A single mockable API operation, parsed from one `paths..` entry. * @@ -79,6 +109,12 @@ public data class ApiSpec( * 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". + * @property requestBodyMatch Narrow request-body match constraints (required fields and/or a + * discriminator value), or `null` if this operation declares no `requestBody`, its schema + * yields nothing to check, or the schema simply isn't declared — an operation with `null` + * 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]). * @see ApiSpec * @see com.worldline.devview.networkmock.core.repository.RequestMatcher */ @@ -92,7 +128,8 @@ public data class Operation( val queryParameters: Map? = null, val delayMs: Long? = null, val version: String? = null, - val failureRate: Double? = null + val failureRate: Double? = null, + val requestBodyMatch: RequestBodyMatch? = null ) /** diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiDocument.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiDocument.kt index 4008ba3f..aafb2eb8 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiDocument.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiDocument.kt @@ -21,11 +21,11 @@ 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`, request bodies, …) is silently ignored via lenient/ - * non-strict decoding — this parser mocks, it does not validate. [SchemaObject] is the one - * exception: a `content..schema` is read to *synthesize* a response body when a - * spec declares no `examples` for a status code (see [SchemaSynthesizer]) — still not - * validation, just a fallback so a schema-only response isn't unmockable. + * (`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 + * 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. */ @Serializable internal data class OpenApiDocument( @@ -73,6 +73,7 @@ internal data class OperationObject( val operationId: String? = null, val summary: String? = null, val parameters: List = emptyList(), + val requestBody: RequestBodyObject? = null, val responses: Map = emptyMap(), @SerialName("x-devview") val xDevview: DevViewExtension? = null ) @@ -134,14 +135,32 @@ internal data class ExampleObject( val externalValue: String? = null ) +/** + * A `requestBody` declaration for an operation, or a `$ref` to one under + * `components.requestBodies`. + * + * Only [content] is modeled — read for its `.schema`, and only to build the + * declaring operation's [com.worldline.devview.networkmock.core.model.RequestBodyMatch] (see + * [OpenApiParser]'s request-body matching scope decision). No other `requestBody` field + * (`description`, `required`) is read. + */ +@Serializable +internal data class RequestBodyObject( + @SerialName("\$ref") val ref: String? = null, + val content: Map = emptyMap() +) + /** * A JSON Schema (OpenAPI's constrained subset of it) declaration, or a `$ref` to one under - * `components.schemas`. Read only to synthesize a placeholder response body when a - * `content.` declares a [schema] but no `examples` — see [SchemaSynthesizer]. + * `components.schemas`. Read in two narrow, non-validating ways: to synthesize a placeholder + * response body when a `content.` declares a [schema] but no `examples` (see + * [SchemaSynthesizer]), and to build a [RequestBodyObject]'s + * [com.worldline.devview.networkmock.core.model.RequestBodyMatch] (see [OpenApiParser]'s + * request-body matching scope decision). * - * Deliberately not a full JSON Schema model: no `required`, `additionalProperties`, - * `minimum`/`maximum`, string patterns, etc. — anything that would matter for *validation* - * rather than *synthesizing one plausible value*. + * Deliberately not a full JSON Schema model: no `additionalProperties`, `minimum`/`maximum`, + * string patterns, etc. — anything that would matter for *validation* rather than *synthesizing + * one plausible value* or checking narrow request-body match constraints. * * @property type The schema's declared type (`"string"`, `"integer"`, `"number"`, `"boolean"`, * `"object"`, or `"array"`). May be absent when [properties] or [items] alone implies it. @@ -160,7 +179,13 @@ internal data class ExampleObject( * @property oneOf Alternative schemas; [SchemaSynthesizer] synthesizes the first declared * variant regardless of [discriminator] (see [DiscriminatorObject]'s KDoc for why). * @property discriminator Parsed but not currently used to select a `oneOf` variant — there is - * no concrete request/response data at spec-parse time to disambiguate against. + * no concrete request/response data at spec-parse time to disambiguate against. Read for + * request-body matching, though (see [required]): its [DiscriminatorObject.propertyName] + * becomes a [com.worldline.devview.networkmock.core.model.RequestBodyMatch.discriminatorField]. + * @property required Property names a request-body schema declares as required, read only to + * build [com.worldline.devview.networkmock.core.model.RequestBodyMatch.requiredFields] — + * [SchemaSynthesizer] ignores this entirely, a synthesized response always includes every + * [properties] entry regardless of whether it's "required". */ @Serializable internal data class SchemaObject( @@ -173,7 +198,8 @@ internal data class SchemaObject( val format: String? = null, val allOf: List? = null, val oneOf: List? = null, - val discriminator: DiscriminatorObject? = null + val discriminator: DiscriminatorObject? = null, + val required: List? = null ) /** @@ -192,7 +218,8 @@ internal data class ComponentsObject( val responses: Map = emptyMap(), val examples: Map = emptyMap(), val headers: Map = emptyMap(), - val schemas: Map = emptyMap() + val schemas: Map = emptyMap(), + val requestBodies: Map = emptyMap() ) /** diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiParser.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiParser.kt index ab8b5e6f..f388d43b 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiParser.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiParser.kt @@ -3,6 +3,7 @@ package com.worldline.devview.networkmock.core.openapi import com.worldline.devview.networkmock.core.NetworkMockResourceLoader import com.worldline.devview.networkmock.core.model.ApiSpec import com.worldline.devview.networkmock.core.model.Operation +import com.worldline.devview.networkmock.core.model.RequestBodyMatch import kotlinx.serialization.json.Json /** @@ -57,7 +58,14 @@ internal data class ResolvedResponse( * Each hop's fragment must declare the section the caller expects (e.g. a response `$ref` * must point into `components/responses`), so a same-named entry in a different section * is never silently conflated with the one actually referenced. - * - Request bodies are not read at all (see #83, explicitly out of scope for 0.2.0). + * - `requestBody` matching is deliberately narrow (see #83): only a + * `requestBody.content..schema`'s `required` field list and, optionally, a single + * discriminator property's literal value (from that property's own single-value `enum`) are + * read into [RequestBodyMatch] — not full JSON Schema validation. An operation whose schema + * yields neither a required field nor a usable discriminator value has `requestBodyMatch == + * 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). */ internal object OpenApiParser { /** @@ -108,7 +116,11 @@ internal object OpenApiParser { queryParameters = queryParameters, delayMs = rawOperation.xDevview?.delayMs, version = versionPattern.find(input = path)?.groupValues?.get(index = 1), - failureRate = rawOperation.xDevview?.failureRate + failureRate = rawOperation.xDevview?.failureRate, + requestBodyMatch = context.buildRequestBodyMatch( + raw = rawOperation.requestBody, + document = document + ) ) responseIndex[operationId] = context.resolveResponseIndex( @@ -145,6 +157,15 @@ internal object OpenApiParser { @Suppress("DocumentationOverPrivateProperty") private const val SYNTHESIZED_EXAMPLE_NAME = "default" + /** + * The media type [ParseContext.buildRequestBodyMatch] prefers when a `requestBody` declares + * more than one — this library assumes one dominant request content type per operation, + * mirroring how [ParameterObject.example] models a single literal value rather than a + * per-media-type one. + */ + @Suppress("DocumentationOverPrivateProperty") + private const val APPLICATION_JSON = "application/json" + /** * Extracts a display-only `v{n}` version tag from a `/v{n}/` path segment (see * [Operation.version]). Not currently configurable — see the KDoc there. @@ -289,6 +310,62 @@ internal object OpenApiParser { ) } + /** Resolves a `requestBody`'s own `$ref` (if any) via [resolveRef] against `components.requestBodies`. */ + @Suppress("DocumentationOverPrivateFunction") + private suspend fun resolveRequestBody( + raw: RequestBodyObject, + document: OpenApiDocument + ): RequestBodyObject { + val ref = raw.ref ?: return raw + return resolveRef( + ref = ref, + document = document, + section = "requestBodies", + componentsOf = { it.components.requestBodies }, + refOf = { it.ref } + ) + } + + /** + * Builds the [RequestBodyMatch] for an operation's [RequestBodyObject], or `null` if + * the operation declares no `requestBody`, its chosen media type (see [APPLICATION_JSON]) + * has no `schema`, or the resolved schema yields nothing to check — no `required` fields + * and no usable discriminator (see [RequestBodyMatch]'s KDoc for what "usable" means). + */ + @Suppress("DocumentationOverPrivateFunction") + suspend fun buildRequestBodyMatch( + raw: RequestBodyObject?, + document: OpenApiDocument + ): RequestBodyMatch? { + val requestBody = raw?.let { resolveRequestBody(raw = it, document = document) } + ?: return null + val rawSchema = + (requestBody.content[APPLICATION_JSON] ?: requestBody.content.values.firstOrNull()) + ?.schema + ?: return null + val schema = resolveSchema(raw = rawSchema, document = document) + + val requiredFields = schema.required.orEmpty() + val discriminatorField = schema.discriminator?.propertyName?.takeIf { it.isNotBlank() } + val discriminatorValue = discriminatorField + ?.let { field -> + schema.properties + ?.get(key = field) + ?.enum + ?.firstOrNull() + } + + return if (requiredFields.isEmpty() && discriminatorField == null) { + null + } else { + RequestBodyMatch( + requiredFields = requiredFields, + discriminatorField = discriminatorField, + discriminatorValue = discriminatorValue + ) + } + } + /** Resolves each declared header's `$ref` (if any) down to its literal `example` value. */ @Suppress("DocumentationOverPrivateFunction") private suspend fun resolveHeaders( diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepository.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepository.kt index 9a7e7699..4f7b010d 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepository.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepository.kt @@ -106,21 +106,27 @@ public class MockConfigRepository( * Finds a matching operation for an incoming HTTP request. * * Specs are checked in configuration order. Within a spec whose [servers][com.worldline.devview.networkmock.core.model.ApiSpec.servers] - * include a hostname matching [host], the first operation whose path, method, and query - * parameters all match wins. If no operation in that spec matches, the next spec is - * tried — two specs may legitimately share a hostname, and the first spec that actually - * has a matching operation wins. + * include a hostname matching [host], the first operation whose path, method, query + * parameters, and (if it declares constraints) request body all match wins. If no operation + * in that spec matches, the next spec is tried — two specs may legitimately share a + * hostname, and the first spec that actually has a matching operation wins. Request-body + * matching only disambiguates operations that already collide on path/method/query — see + * [com.worldline.devview.networkmock.core.model.RequestBodyMatch]. * * @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"`) + * @param requestBody The request body as text, or `null` if none was read. Only checked + * against operations that declare their own [com.worldline.devview.networkmock.core.model.RequestBodyMatch] + * — operations without one match regardless of this value. * @return A [MockMatch] if a matching operation is found, or `null` otherwise */ public suspend fun findMatchingMock( host: String, path: String, method: String, - queryParameters: Map> = emptyMap() + queryParameters: Map> = emptyMap(), + requestBody: String? = null ): MockMatch? { val config = loadConfiguration().getOrNull() ?: return null @@ -136,6 +142,10 @@ public class MockConfigRepository( RequestMatcher.matchesQueryParams( configQueryParams = operation.queryParameters, requestQueryParams = queryParameters + ) && + RequestMatcher.matchesRequestBody( + configMatch = operation.requestBodyMatch, + requestBody = requestBody ) } ?: return@firstNotNullOfOrNull null diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcher.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcher.kt index 6435d7b9..417d7a35 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcher.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcher.kt @@ -1,5 +1,11 @@ package com.worldline.devview.networkmock.core.repository +import com.worldline.devview.networkmock.core.model.RequestBodyMatch +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + /** * Utility object for matching HTTP request paths against configured endpoint paths. * @@ -148,6 +154,57 @@ public object RequestMatcher { } } + /** + * Checks if a request body satisfies an operation's [RequestBodyMatch] constraints, if any. + * + * This is **not** JSON Schema validation — only [RequestBodyMatch.requiredFields] presence + * and, if declared, [RequestBodyMatch.discriminatorField]'s value are checked, mirroring how + * lenient [matchesPath]/[matchesQueryParams] already are. + * + * ## Matching Rules + * 1. `null` [configMatch] always matches — an operation with no declared `requestBody` + * constraints matches any body (or none at all). + * 2. A `null` [requestBody], or one that isn't a valid JSON object, never matches a non-null + * [configMatch] — there's nothing to check required fields or a discriminator against. + * 3. Every [RequestBodyMatch.requiredFields] entry must be present as a top-level key. + * 4. If [RequestBodyMatch.discriminatorField] is declared, it must be present as a + * top-level key; if [RequestBodyMatch.discriminatorValue] is also declared, that key's + * value must equal it exactly (as a JSON primitive's textual content). + * + * @param configMatch The operation's declared constraints, or `null` if it declares none + * @param requestBody The actual incoming request body as text, or `null` if none was read + * @return `true` if [requestBody] satisfies [configMatch] (or [configMatch] is `null`) + */ + public fun matchesRequestBody(configMatch: RequestBodyMatch?, requestBody: String?): Boolean { + if (configMatch == null) return true + val json = requestBody?.let { parseJsonObject(requestBody = it) } ?: return false + + val hasRequiredFields = configMatch.requiredFields.all { field -> + json.containsKey(key = field) + } + val matchesDiscriminator = configMatch.discriminatorField?.let { field -> + val actualValue = (json[field] as? JsonPrimitive)?.content + actualValue != null && + ( + configMatch.discriminatorValue == null || + actualValue == configMatch.discriminatorValue + ) + } ?: true + + return hasRequiredFields && matchesDiscriminator + } + + /** + * Parses [requestBody] as a JSON object for [matchesRequestBody], or `null` if it isn't + * valid JSON, or is valid JSON that isn't an object (e.g. a bare array or primitive). + */ + @Suppress("DocumentationOverPrivateFunction") + private fun parseJsonObject(requestBody: String): JsonObject? = try { + Json.parseToJsonElement(string = requestBody) as? JsonObject + } catch (@Suppress("SwallowedException") e: SerializationException) { + null + } + /** * Checks if a path segment is a parameter (enclosed in curly braces). * diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt index cc1c221d..abaf6544 100644 --- a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt @@ -255,6 +255,214 @@ class MockConfigRepositoryTest { noMatch.shouldBeNull() } + @Test + fun `parses required fields from a requestBody schema into requestBodyMatch`() = runTest { + val spec = """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://api.example.com" } ], + "paths": { + "/api/users": { + "post": { + "operationId": "createUser", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name", "email"] + } + } + } + }, + "responses": {} + } + } + } + } + """.trimIndent() + val repository = createRepository(resources = mapOf(SPEC_PATH to spec)) + + val config = repository.loadConfiguration().getOrThrow() + + val operation = config.specs[0].operations.single() + operation.requestBodyMatch?.requiredFields shouldContainExactly listOf("name", "email") + operation.requestBodyMatch?.discriminatorField.shouldBeNull() + } + + @Test + fun `parses discriminator field and its single-value enum from a requestBody schema`() = runTest { + val spec = """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://api.example.com" } ], + "paths": { + "/api/payments": { + "post": { + "operationId": "createCardPayment", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "discriminator": { "propertyName": "type" }, + "properties": { + "type": { "type": "string", "enum": ["card"] } + } + } + } + } + }, + "responses": {} + } + } + } + } + """.trimIndent() + val repository = createRepository(resources = mapOf(SPEC_PATH to spec)) + + val config = repository.loadConfiguration().getOrThrow() + + val requestBodyMatch = config.specs[0].operations.single().requestBodyMatch + requestBodyMatch?.discriminatorField shouldBe "type" + requestBodyMatch?.discriminatorValue shouldBe "card" + } + + @Test + fun `requestBodyMatch is null when an operation declares no requestBody`() = runTest { + val repository = createRepository(resources = baseResources()) + + val config = repository.loadConfiguration().getOrThrow() + + config.specs[0].operations.first { it.operationId == "getUser" } + .requestBodyMatch.shouldBeNull() + } + + @Test + fun `requestBodyMatch is null when the requestBody schema has neither required fields nor a discriminator`() = + runTest { + val spec = """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://api.example.com" } ], + "paths": { + "/api/users": { + "post": { + "operationId": "createUser", + "requestBody": { + "content": { + "application/json": { + "schema": { "type": "object" } + } + } + }, + "responses": {} + } + } + } + } + """.trimIndent() + val repository = createRepository(resources = mapOf(SPEC_PATH to spec)) + + val config = repository.loadConfiguration().getOrThrow() + + config.specs[0].operations.single().requestBodyMatch.shouldBeNull() + } + + @Test + fun `findMatchingMock disambiguates two operations colliding on path and method by request body shape`() = + runTest { + val spec = """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://api.example.com" } ], + "paths": { + "/api/payments/card": { + "post": { + "operationId": "payByCard", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "discriminator": { "propertyName": "type" }, + "properties": { "type": { "type": "string", "enum": ["card"] } } + } + } + } + }, + "responses": {} + } + } + } + } + """.trimIndent() + val repository = createRepository(resources = mapOf(SPEC_PATH to spec)) + + val matchesCard = repository.findMatchingMock( + host = "api.example.com", + path = "/api/payments/card", + method = "POST", + requestBody = """{"type":"card","number":"4242"}""" + ) + val doesNotMatchOtherType = repository.findMatchingMock( + host = "api.example.com", + path = "/api/payments/card", + method = "POST", + requestBody = """{"type":"bank_transfer"}""" + ) + + matchesCard?.operationId shouldBe "payByCard" + doesNotMatchOtherType.shouldBeNull() + } + + @Test + fun `findMatchingMock resolves a dollar-ref'd requestBody schema via components schemas`() = runTest { + val spec = """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://api.example.com" } ], + "paths": { + "/api/users": { + "post": { + "operationId": "createUser", + "requestBody": { + "content": { + "application/json": { + "schema": { "${'$'}ref": "#/components/schemas/NewUser" } + } + } + }, + "responses": {} + } + } + }, + "components": { + "schemas": { + "NewUser": { "type": "object", "required": ["email"] } + } + } + } + """.trimIndent() + val repository = createRepository(resources = mapOf(SPEC_PATH to spec)) + + val matches = repository.findMatchingMock( + host = "api.example.com", + path = "/api/users", + method = "POST", + requestBody = """{"email":"bob@example.com"}""" + ) + val noMatch = repository.findMatchingMock( + host = "api.example.com", + path = "/api/users", + method = "POST", + requestBody = """{"name":"Bob"}""" + ) + + matches?.operationId shouldBe "createUser" + noMatch.shouldBeNull() + } + @Test fun `findMatchingMock picks the first spec that has a matching operation when hosts collide`() = runTest { diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcherTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcherTest.kt index 0595e538..f4bbc6e3 100644 --- a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcherTest.kt +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcherTest.kt @@ -1,5 +1,6 @@ package com.worldline.devview.networkmock.core.repository +import com.worldline.devview.networkmock.core.model.RequestBodyMatch import io.kotest.matchers.shouldBe import kotlin.test.Test @@ -431,5 +432,135 @@ class RequestMatcherTest { } // endregion + + // region Request body matching + + @Test + fun `matchesRequestBody returns true when configMatch is null regardless of body`() { + RequestMatcher.matchesRequestBody( + configMatch = null, + requestBody = """{"anything":"goes"}""" + ) shouldBe true + RequestMatcher.matchesRequestBody(configMatch = null, requestBody = null) shouldBe true + } + + @Test + fun `matchesRequestBody returns false when configMatch is non-null but requestBody is null`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch(requiredFields = listOf("name")), + requestBody = null + ) shouldBe false + } + + @Test + fun `matchesRequestBody returns false when requestBody is not valid JSON`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch(requiredFields = listOf("name")), + requestBody = "not json" + ) shouldBe false + } + + @Test + fun `matchesRequestBody returns false when requestBody is valid JSON but not an object`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch(requiredFields = listOf("name")), + requestBody = """["name"]""" + ) shouldBe false + } + + @Test + fun `matchesRequestBody returns true when all required fields are present`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch(requiredFields = listOf("name", "email")), + requestBody = """{"name":"Bob","email":"bob@example.com"}""" + ) shouldBe true + } + + @Test + fun `matchesRequestBody returns false when a required field is missing`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch(requiredFields = listOf("name", "email")), + requestBody = """{"name":"Bob"}""" + ) shouldBe false + } + + @Test + fun `matchesRequestBody ignores the required field's actual value, only checks presence`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch(requiredFields = listOf("name")), + requestBody = """{"name":null}""" + ) shouldBe true + } + + @Test + fun `matchesRequestBody returns true when discriminator field matches expected value`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch( + discriminatorField = "type", + discriminatorValue = "card" + ), + requestBody = """{"type":"card","number":"4242"}""" + ) shouldBe true + } + + @Test + fun `matchesRequestBody returns false when discriminator field has a different value`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch( + discriminatorField = "type", + discriminatorValue = "card" + ), + requestBody = """{"type":"bank_transfer","iban":"..."}""" + ) shouldBe false + } + + @Test + fun `matchesRequestBody returns false when discriminator field is absent from the body`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch( + discriminatorField = "type", + discriminatorValue = "card" + ), + requestBody = """{"number":"4242"}""" + ) shouldBe false + } + + @Test + fun `matchesRequestBody only checks discriminator field presence when no discriminatorValue is declared`() { + val configMatch = RequestBodyMatch(discriminatorField = "type", discriminatorValue = null) + + RequestMatcher.matchesRequestBody( + configMatch = configMatch, + requestBody = """{"type":"anything"}""" + ) shouldBe true + RequestMatcher.matchesRequestBody( + configMatch = configMatch, + requestBody = """{"other":"field"}""" + ) shouldBe false + } + + @Test + fun `matchesRequestBody requires both required fields and discriminator when both are declared`() { + val configMatch = RequestBodyMatch( + requiredFields = listOf("amount"), + discriminatorField = "type", + discriminatorValue = "card" + ) + + RequestMatcher.matchesRequestBody( + configMatch = configMatch, + requestBody = """{"amount":100,"type":"card"}""" + ) shouldBe true + RequestMatcher.matchesRequestBody( + configMatch = configMatch, + requestBody = """{"type":"card"}""" + ) shouldBe false + RequestMatcher.matchesRequestBody( + configMatch = configMatch, + requestBody = """{"amount":100,"type":"bank_transfer"}""" + ) shouldBe false + } + + // endregion } diff --git a/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt b/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt index 0fcf2e93..313b6285 100644 --- a/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt +++ b/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt @@ -19,6 +19,7 @@ import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode +import io.ktor.http.content.OutgoingContent import io.ktor.http.headersOf import io.mockk.coEvery import io.mockk.every @@ -300,6 +301,173 @@ class NetworkMockPluginTest { // endregion + // region Request body matching + + @Test + fun requestBodyDisambiguation_selectsCorrectOperationByDiscriminatorValue() = runTest { + val resources = requestBodyDisambiguationResources() + val state = NetworkMockState( + globalMockingEnabled = true, + operationStates = mapOf( + "card-payByCard" to OperationMockState.Mock(statusCode = 200, exampleName = "default"), + "bank-payByBankTransfer" to OperationMockState.Mock(statusCode = 200, exampleName = "default") + ) + ) + val client = buildClient( + engine = networkEngine(body = """{"source":"network"}"""), + configRepository = requestBodyDisambiguationRepository(resources = resources), + stateRepository = stateRepositoryMock(state = state) + ) + + val cardResponse = client.post(urlString = "https://staging.api.example.com/api/payments") { + setBody("""{"type":"card","number":"4242"}""") + } + val bankResponse = client.post(urlString = "https://staging.api.example.com/api/payments") { + setBody("""{"type":"bank_transfer","iban":"DE00"}""") + } + + cardResponse.body() shouldBe """{"method":"card"}""" + bankResponse.body() shouldBe """{"method":"bank_transfer"}""" + } + + @Test + fun requestBodyDisambiguation_fallsBackToNetworkWithOriginalBodyIntact_whenNoShapeMatches() = runTest { + val resources = requestBodyDisambiguationResources() + var capturedBody: String? = null + val engine = MockEngine { request -> + capturedBody = (request.body as? OutgoingContent.ByteArrayContent) + ?.bytes() + ?.decodeToString() + respond( + content = """{"source":"network"}""", + status = HttpStatusCode.OK, + headers = headersOf("Content-Type", "application/json") + ) + } + val state = NetworkMockState( + globalMockingEnabled = true, + operationStates = mapOf( + "card-payByCard" to OperationMockState.Mock(statusCode = 200, exampleName = "default"), + "bank-payByBankTransfer" to OperationMockState.Mock(statusCode = 200, exampleName = "default") + ) + ) + val client = buildClient( + engine = engine, + configRepository = requestBodyDisambiguationRepository(resources = resources), + stateRepository = stateRepositoryMock(state = state) + ) + val originalBody = """{"type":"crypto","wallet":"abc123"}""" + + val response: HttpResponse = client.post( + urlString = "https://staging.api.example.com/api/payments" + ) { + setBody(originalBody) + } + + // No declared operation's requestBody shape matches "crypto" - falls through to network, + // and the original body must still reach it byte-for-byte, unconsumed. + response.body() shouldBe """{"source":"network"}""" + capturedBody shouldBe originalBody + } + + /** + * Two specs, sharing a host and declaring the identical `POST /api/payments` path/method, + * disambiguated only by a `type` discriminator in their respective `requestBody` schemas - + * the scenario [MockConfigRepository.findMatchingMock]'s own KDoc describes for why + * request-body matching exists. + */ + private fun requestBodyDisambiguationResources(): Map { + val cardSpec = """ + { + "info": { "title": "Card" }, + "servers": [ { "url": "https://staging.api.example.com" } ], + "paths": { + "/api/payments": { + "post": { + "operationId": "payByCard", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "discriminator": { "propertyName": "type" }, + "properties": { "type": { "type": "string", "enum": ["card"] } } + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "default": { "externalValue": "/files/networkmocks/responses/payByCard-200.json" } + } + } + } + } + } + } + } + } + } + """.trimIndent() + val bankSpec = """ + { + "info": { "title": "Bank" }, + "servers": [ { "url": "https://staging.api.example.com" } ], + "paths": { + "/api/payments": { + "post": { + "operationId": "payByBankTransfer", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "discriminator": { "propertyName": "type" }, + "properties": { "type": { "type": "string", "enum": ["bank_transfer"] } } + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "default": { + "externalValue": "/files/networkmocks/responses/payByBankTransfer-200.json" + } + } + } + } + } + } + } + } + } + } + """.trimIndent() + return mapOf( + "files/networkmocks/specs/card.json" to cardSpec, + "files/networkmocks/specs/bank.json" to bankSpec, + "files/networkmocks/responses/payByCard-200.json" to """{"method":"card"}""", + "files/networkmocks/responses/payByBankTransfer-200.json" to """{"method":"bank_transfer"}""" + ) + } + + private fun requestBodyDisambiguationRepository(resources: Map): MockConfigRepository = + MockConfigRepository( + specPaths = listOf( + "files/networkmocks/specs/card.json", + "files/networkmocks/specs/bank.json" + ), + resourceLoader = KtorPluginTestData.resourceLoader(resources = resources) + ) + + // endregion + // region Failure simulation @Test diff --git a/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt b/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt index 3827c6f7..198a50b3 100644 --- a/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt +++ b/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt @@ -63,6 +63,10 @@ public data class NetworkMockPluginConfig(internal val config: NetworkMockConfig * - **Global Toggle**: Master switch to enable/disable all mocking * - **Path Parameters**: Supports path parameters like `/users/{userId}` * - **Multiple Hosts**: Can mock different hosts (staging, production, etc.) + * - **Request Body Disambiguation**: When two operations would otherwise collide on path, + * method, and query, a request body's shape (required fields, and/or a discriminator field's + * value — see [com.worldline.devview.networkmock.core.model.RequestBodyMatch]) picks the + * right one. Narrow matching, not full schema validation. * - **State Persistence**: Mock configuration persists across app restarts * - **Failure Simulation**: An operation can deterministically simulate a network failure * (see [com.worldline.devview.networkmock.core.model.OperationMockState.Failure]), or fail a @@ -71,7 +75,8 @@ public data class NetworkMockPluginConfig(internal val config: NetworkMockConfig * ## How It Works * 1. Plugin intercepts every HTTP request using Ktor's `HttpSend` mechanism * 2. Checks if global mocking is enabled via DataStore state - * 3. Attempts to match the request (host, path, method) to a configured endpoint + * 3. Attempts to match the request (host, path, method, query, and — for operations that + * declare their own constraints — request body) to a configured endpoint * 4. If matched and mock is enabled for that endpoint, loads and returns the mock response * 5. Otherwise, proceeds with the actual network call * @@ -172,6 +177,7 @@ public val NetworkMockPlugin: HttpClientPlugin key to values } + val requestBodyText = extractRequestBodyText(content = request.body) val currentState = cachedState.value ?: stateRepository.getState() @@ -184,7 +190,8 @@ public val NetworkMockPlugin: HttpClientPlugin.content..examples.`** → one entry per response variant this operation can mock; `externalValue` points at the response body file on disk. By convention the primary/original response for a status code is named `"default"`. - **`parameters` with `in: query`** → a literal `example` value on a query parameter becomes a required match for that operation (e.g. `listUsers` only matches requests carrying `?type=user`). +- **`requestBody.content..schema`** → optionally disambiguates operations that collide on path/method/query — see [Request body matching](#request-body-matching) below. - **`x-devview.delayMs`** → simulated response delay, at the document root (spec-wide default) and/or per operation (overrides the default). See [x-devview extension](#x-devview-extension) below. - **`{param}` placeholders**: Path segments like `{userId}` match any value during request matching. @@ -65,11 +66,12 @@ configurable. ## Request Matching -`MockConfigRepository.findMatchingMock(host, path, method, queryParameters)` resolves a mock in three steps: +`MockConfigRepository.findMatchingMock(host, path, method, queryParameters, requestBody)` resolves a mock in four steps: 1. **Hostname match** — compares the request host (case-insensitive) against every hostname declared in the spec's `servers[]`. If two specs both declare a matching hostname, the first spec (in configuration order) that also has a matching operation wins. 2. **Path match** — splits path by `/`, compares segment by segment; `{param}` segments match any value; non-param segments are case-sensitive. 3. **Method match** — case-sensitive exact match. Use uppercase (`"GET"`, `"POST"`). +4. **Request body match** (only for operations that declare one — see below) — narrow, non-validating checks against the operation's declared `requestBody` schema. `Operation.method` is typed as `HttpMethod`, a small value class modeled after Ktor's own `io.ktor.http.HttpMethod` (open set, `HttpMethod.Get`/`.Post`/etc. constants, plus @@ -80,6 +82,52 @@ receives the raw wire value from `devview-networkmock-ktor`. There is no stored active-server selection. The matching server is determined purely from the request hostname at interception time. +### Request body matching + +An operation's `requestBody.content..schema` (`application/json` if declared, +otherwise whichever media type comes first) is read into a narrow `RequestBodyMatch` — this +exists purely to **disambiguate** operations that would otherwise collide on path, method, and +query alone (e.g. two specs sharing a host, each declaring `POST /api/payments`, differing only +by body shape); it is not a substitute for path/method/query matching, and an operation without +`requestBodyMatch` still matches any body. + +```json +"requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["amount"], + "discriminator": { "propertyName": "type" }, + "properties": { + "type": { "type": "string", "enum": ["card"] } + } + } + } + } +} +``` + +Two things are read from the schema, both deliberately narrow (not full JSON Schema validation): + +| Schema field | Becomes | Matching rule | +|---|---|---| +| `required` | `RequestBodyMatch.requiredFields` | every listed property must be a top-level key in the request body | +| `discriminator.propertyName` | `RequestBodyMatch.discriminatorField` | that property must be a top-level key | +| that property's own single-value `enum` | `RequestBodyMatch.discriminatorValue` | if present, the key's value must equal it exactly | + +If the schema yields neither a required field nor a usable discriminator (no `discriminator`, or +one whose property doesn't declare a single-value `enum`), `requestBodyMatch` is `null` — same +as an operation declaring no `requestBody` at all. `$ref`s (both the `requestBody` itself, under +`components.requestBodies`, and its schema, under `components.schemas`) resolve the same way as +elsewhere in this document. + +`devview-networkmock-ktor`'s plugin only reads the request body when it's already a fully +in-memory `OutgoingContent.ByteArrayContent` (the shape Ktor's content negotiation produces for +a JSON-serialized body) — a streaming or multipart body is never touched, and `requestBodyMatch` +simply doesn't apply to it (treated as no body). Reading it is a pure, repeatable operation that +never consumes or mutates anything the real network call still needs to send. + ## Response Variant Discovery Response bodies live wherever `externalValue` points them — the sample app uses `composeResources/files/networkmocks/responses/{specId}/{operationId}/{operationId}-{status}[-{suffix}].json`, but this is only a convention, not a requirement. From 72362957928efe284460e1cfeb7127a051a2395b Mon Sep 17 00:00:00 2001 From: Maxime MICHEL Date: Wed, 23 Sep 2026 16:50:13 +0200 Subject: [PATCH 2/4] :white_check_mark: Add YAML regression test for dollar-ref schemas declared after paths Answers a question about whether the schema synthesis and requestBody matching work raised in PR review: a real-world YAML spec (schemas at the end of the file, three response codes ref-ing the same schema, a requestBody with its own ref and a requestBody.required boolean, folded summary strings, tags block sequence) now has explicit regression coverage. No existing test previously exercised the full YAML pipeline end to end (isYaml sniffing, kaml decode, ref resolution, schema synthesis) - only JSON string fixtures were used throughout MockConfigRepositoryTest, plus an unrelated kaml library smoke test. Confirms strictMode false lenient decoding, order-independent components.schemas lookup, and SchemaSynthesizer resolving a bare top-level dollar-ref before checking type/properties all work together as intended - no code changes needed, this closes a coverage gap only. --- .../repository/MockConfigRepositoryTest.kt | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt index abaf6544..c67829ad 100644 --- a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt @@ -1010,6 +1010,98 @@ class MockConfigRepositoryTest { responses.single().content shouldBe """{"id":0}""" } + @Test + fun `parses a real-world YAML spec with dollar-ref schemas declared after paths`() = runTest { + // Mirrors a shape seen in real API docs: a YAML spec whose components.schemas section + // sits after paths, a requestBody with its own $ref'd schema and a requestBody.required + // 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). + val yamlSpec = """ + info: + title: Example + servers: + - url: https://api.example.com + paths: + /api/v1/authentication/mobile-auth/login: + post: + operationId: mobileLogin + requestBody: + content: + application/json: + schema: + ${'$'}ref: "#/components/schemas/MobileLoginRequest" + required: true + responses: + "200": + content: + application/json: + schema: + ${'$'}ref: "#/components/schemas/MobileLoginResponse" + description: "Successful call, returns a challenge that needs to be signed\ + \ to complete the activation" + "401": + content: + application/json: + schema: + ${'$'}ref: "#/components/schemas/MobileLoginResponse" + description: User not authenticated + "422": + content: + application/json: + schema: + ${'$'}ref: "#/components/schemas/MobileLoginResponse" + description: Invalid parameters + summary: Init mobile authentication activation workflow. It will reset any previously + activated mobile authentication for this user and device. + tags: + - Authentication V1 + - Authentication + components: + schemas: + MobileLoginRequest: + type: object + required: + - deviceId + properties: + deviceId: + type: string + MobileLoginResponse: + type: object + properties: + challenge: + type: string + expiresInSeconds: + type: integer + """.trimIndent() + val yamlSpecPath = "specs/mobile-auth.yaml" + val repository = MockConfigRepository( + specPaths = listOf(yamlSpecPath), + resourceLoader = RecordingResourceLoader(resources = mapOf(yamlSpecPath to yamlSpec)) + ) + + val config = repository.loadConfiguration().getOrThrow() + val operation = config.specs[0].operations.single() + + operation.operationId shouldBe "mobileLogin" + operation.path shouldBe "/api/v1/authentication/mobile-auth/login" + operation.method shouldBe HttpMethod.Post + // Folded YAML scalar: the line break becomes a single space. + 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") + + val responses = repository.discoverResponseFiles( + key = OperationKey(specId = "example", operationId = "mobileLogin") + ) + + responses shouldHaveSize 3 + responses.map { it.statusCode }.sorted() shouldContainExactly listOf(200, 401, 422) + responses.all { it.isSynthesized } shouldBe true + responses.all { it.content == """{"challenge":"string","expiresInSeconds":0}""" } shouldBe true + } + @Test fun `discoverResponseFiles returns responses sorted by status code`() = runTest { val repository = createRepository(resources = baseResources()) From a70f1cd95f451070d18fdbc2478447f14397f710 Mon Sep 17 00:00:00 2001 From: Maxime MICHEL Date: Wed, 23 Sep 2026 17:19:48 +0200 Subject: [PATCH 3/4] :recycle: Use multi-dollar string interpolation for dollar-ref test fixtures Kotlin 2.2+'s multi-dollar string literals (dollar-dollar-quote-quote-quote) let a JSON/YAML fixture write a literal dollar-ref directly instead of the escaped dollar-quote-dollar-quote form every dollar-ref-containing fixture in this file needed before. Purely a readability cleanup of existing test fixtures across the dollar-ref chain and requestBody-matching tests plus the new YAML regression test - no behavior change, no new coverage. Verified: detektFull, cleanTestAndroidHostTest, testAndroidHostTest all green (44/44 in MockConfigRepositoryTest). --- .../repository/MockConfigRepositoryTest.kt | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt index c67829ad..ca675f1a 100644 --- a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt @@ -418,7 +418,7 @@ class MockConfigRepositoryTest { @Test fun `findMatchingMock resolves a dollar-ref'd requestBody schema via components schemas`() = runTest { - val spec = """ + val spec = $$""" { "info": { "title": "Example" }, "servers": [ { "url": "https://api.example.com" } ], @@ -429,7 +429,7 @@ class MockConfigRepositoryTest { "requestBody": { "content": { "application/json": { - "schema": { "${'$'}ref": "#/components/schemas/NewUser" } + "schema": { "$ref": "#/components/schemas/NewUser" } } } }, @@ -662,7 +662,7 @@ class MockConfigRepositoryTest { @Test fun `local dollar-ref to a components response resolves correctly`() = runTest { - val spec = """ + val spec = $$""" { "info": { "title": "Example" }, "servers": [ { "url": "https://api.example.com" } ], @@ -671,7 +671,7 @@ class MockConfigRepositoryTest { "get": { "operationId": "getUser", "responses": { - "200": { "${'$'}ref": "#/components/responses/UserOk" } + "200": { "$ref": "#/components/responses/UserOk" } } } } @@ -709,7 +709,7 @@ class MockConfigRepositoryTest { @Test fun `external dollar-ref to another file's components resolves correctly`() = runTest { - val spec = """ + val spec = $$""" { "info": { "title": "Example" }, "servers": [ { "url": "https://api.example.com" } ], @@ -718,7 +718,7 @@ class MockConfigRepositoryTest { "get": { "operationId": "getUser", "responses": { - "200": { "${'$'}ref": "./common.json#/components/responses/UserOk" } + "200": { "$ref": "./common.json#/components/responses/UserOk" } } } } @@ -764,7 +764,7 @@ class MockConfigRepositoryTest { @Test fun `dollar-ref naming the wrong components section is rejected even if a same-named entry exists there`() = runTest { - val spec = """ + val spec = $$""" { "info": { "title": "Example" }, "servers": [ { "url": "https://api.example.com" } ], @@ -773,7 +773,7 @@ class MockConfigRepositoryTest { "get": { "operationId": "getUser", "responses": { - "200": { "${'$'}ref": "#/components/parameters/UserOk" } + "200": { "$ref": "#/components/parameters/UserOk" } } } } @@ -808,7 +808,7 @@ class MockConfigRepositoryTest { @Test fun `local dollar-ref chain of two hops resolves to the final non-ref entry`() = runTest { - val spec = """ + val spec = $$""" { "info": { "title": "Example" }, "servers": [ { "url": "https://api.example.com" } ], @@ -817,14 +817,14 @@ class MockConfigRepositoryTest { "get": { "operationId": "getUser", "responses": { - "200": { "${'$'}ref": "#/components/responses/A" } + "200": { "$ref": "#/components/responses/A" } } } } }, "components": { "responses": { - "A": { "${'$'}ref": "#/components/responses/B" }, + "A": { "$ref": "#/components/responses/B" }, "B": { "content": { "application/json": { @@ -855,7 +855,7 @@ class MockConfigRepositoryTest { @Test fun `cyclic dollar-ref chain fails clearly instead of hanging`() = runTest { - val spec = """ + val spec = $$""" { "info": { "title": "Example" }, "servers": [ { "url": "https://api.example.com" } ], @@ -864,15 +864,15 @@ class MockConfigRepositoryTest { "get": { "operationId": "getUser", "responses": { - "200": { "${'$'}ref": "#/components/responses/A" } + "200": { "$ref": "#/components/responses/A" } } } } }, "components": { "responses": { - "A": { "${'$'}ref": "#/components/responses/B" }, - "B": { "${'$'}ref": "#/components/responses/A" } + "A": { "$ref": "#/components/responses/B" }, + "B": { "$ref": "#/components/responses/A" } } } } @@ -970,7 +970,7 @@ class MockConfigRepositoryTest { @Test fun `discoverResponseFiles resolves a dollar-ref'd schema via components schemas before synthesizing`() = runTest { - val spec = """ + val spec = $$""" { "info": { "title": "Example" }, "servers": [ { "url": "https://api.example.com" } ], @@ -982,7 +982,7 @@ class MockConfigRepositoryTest { "200": { "content": { "application/json": { - "schema": { "${'$'}ref": "#/components/schemas/User" } + "schema": { "$ref": "#/components/schemas/User" } } } } @@ -1018,7 +1018,7 @@ class MockConfigRepositoryTest { // 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). - val yamlSpec = """ + val yamlSpec = $$""" info: title: Example servers: @@ -1031,27 +1031,27 @@ class MockConfigRepositoryTest { content: application/json: schema: - ${'$'}ref: "#/components/schemas/MobileLoginRequest" + $ref: "#/components/schemas/MobileLoginRequest" required: true responses: "200": content: application/json: schema: - ${'$'}ref: "#/components/schemas/MobileLoginResponse" + $ref: "#/components/schemas/MobileLoginResponse" description: "Successful call, returns a challenge that needs to be signed\ \ to complete the activation" "401": content: application/json: schema: - ${'$'}ref: "#/components/schemas/MobileLoginResponse" + $ref: "#/components/schemas/MobileLoginResponse" description: User not authenticated "422": content: application/json: schema: - ${'$'}ref: "#/components/schemas/MobileLoginResponse" + $ref: "#/components/schemas/MobileLoginResponse" description: Invalid parameters summary: Init mobile authentication activation workflow. It will reset any previously activated mobile authentication for this user and device. @@ -1226,7 +1226,7 @@ class MockConfigRepositoryTest { @Test fun `loadMockResponse resolves a dollar-ref'd header via components`() = runTest { val resources = mapOf( - SPEC_PATH to """ + SPEC_PATH to $$""" { "info": { "title": "Example" }, "servers": [ { "url": "https://api.example.com" } ], @@ -1237,7 +1237,7 @@ class MockConfigRepositoryTest { "responses": { "200": { "headers": { - "X-RateLimit-Remaining": { "${'$'}ref": "#/components/headers/RateLimit" } + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit" } }, "content": { "application/json": { From dfb1b3fbe060f1cbea59395c9400b340f10836e4 Mon Sep 17 00:00:00 2001 From: Maxime MICHEL Date: Wed, 23 Sep 2026 18:00:55 +0200 Subject: [PATCH 4/4] :bug: Remove comma from a commonTest backtick test name (illegal on Kotlin/Native) Kotlin/Native's frontend rejects certain punctuation in identifiers derived from backtick-quoted function names, even though the JVM/Android backends accept them fine. RequestMatcherTest's `matchesRequestBody ignores the required field's actual value, only checks presence` test tripped this on iosSimulatorArm64/iosArm64/iosX64 (compileTestKotlinIos*): 'Name contains illegal characters: ,.'. Renamed to replace the comma with 'and' - no other backtick test name in the repo contains a comma (checked repo-wide). Verified locally: :devview-networkmock-core:compileTestKotlinIosSimulatorArm64 now succeeds. --- .../devview/networkmock/core/repository/RequestMatcherTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcherTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcherTest.kt index f4bbc6e3..202bbc07 100644 --- a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcherTest.kt +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcherTest.kt @@ -485,7 +485,7 @@ class RequestMatcherTest { } @Test - fun `matchesRequestBody ignores the required field's actual value, only checks presence`() { + fun `matchesRequestBody ignores the required field's actual value and only checks presence`() { RequestMatcher.matchesRequestBody( configMatch = RequestBodyMatch(requiredFields = listOf("name")), requestBody = """{"name":null}"""